##// END OF EJS Templates
release: merge back stable branch into default
marcink -
r3352:ac6efde5 merge default
parent child Browse files
Show More
@@ -0,0 +1,50 b''
1 |RCE| 4.15.1 |RNS|
2 ------------------
3
4 Release Date
5 ^^^^^^^^^^^^
6
7 - 2019-01-01
8
9
10 New Features
11 ^^^^^^^^^^^^
12
13
14
15 General
16 ^^^^^^^
17
18 - Downloads: properly encode " in the filenames, and add RFC 5987 header for non-ascii files.
19 - Documentation: updated configuration for Nginx and reverse proxy.
20 - VCS: streaming will use now 100kb chunks for faster network throughput.
21
22
23 Security
24 ^^^^^^^^
25
26 - Diffs: fixed xss in context diff menu.
27 - Downloads: properly encode " in the filenames, prevents from hiding executable
28 files disguised in another type of file using crafted file names.
29
30 Performance
31 ^^^^^^^^^^^
32
33
34
35 Fixes
36 ^^^^^
37
38 - VCS: handle excessive slashes in from of the repo name path, fixes #5522.
39 This prevents 500 errors when excessive slashes are used
40 - SVN: support proxy-prefix properly, fixes #5521.
41 - Pull requests: validate ref types on API calls for pull request so users cannot
42 provide wrongs ones.
43 - Scheduler: fix url generation with proxy prefix.
44 - Celery: add DB connection ping to validate DB connection is working at worker startup.
45
46
47 Upgrade notes
48 ^^^^^^^^^^^^^
49
50 - Scheduled release addressing reported problems in 4.15.X releases.
@@ -1,49 +1,50 b''
1 1 1bd3e92b7e2e2d2024152b34bb88dff1db544a71 v4.0.0
2 2 170c5398320ea6cddd50955e88d408794c21d43a v4.0.1
3 3 c3fe200198f5aa34cf2e4066df2881a9cefe3704 v4.1.0
4 4 7fd5c850745e2ea821fb4406af5f4bff9b0a7526 v4.1.1
5 5 41c87da28a179953df86061d817bc35533c66dd2 v4.1.2
6 6 baaf9f5bcea3bae0ef12ae20c8b270482e62abb6 v4.2.0
7 7 32a70c7e56844a825f61df496ee5eaf8c3c4e189 v4.2.1
8 8 fa695cdb411d294679ac081d595ac654e5613b03 v4.3.0
9 9 0e4dc11b58cad833c513fe17bac39e6850edf959 v4.3.1
10 10 8a876f48f5cb1d018b837db28ff928500cb32cfb v4.4.0
11 11 8dd86b410b1aac086ffdfc524ef300f896af5047 v4.4.1
12 12 d2514226abc8d3b4f6fb57765f47d1b6fb360a05 v4.4.2
13 13 27d783325930af6dad2741476c0d0b1b7c8415c2 v4.5.0
14 14 7f2016f352abcbdba4a19d4039c386e9629449da v4.5.1
15 15 416fec799314c70a5c780fb28b3357b08869333a v4.5.2
16 16 27c3b85fafc83143e6678fbc3da69e1615bcac55 v4.6.0
17 17 5ad13deb9118c2a5243d4032d4d9cc174e5872db v4.6.1
18 18 2be921e01fa24bb102696ada596f87464c3666f6 v4.7.0
19 19 7198bdec29c2872c974431d55200d0398354cdb1 v4.7.1
20 20 bd1c8d230fe741c2dfd7100a0ef39fd0774fd581 v4.7.2
21 21 9731914f89765d9628dc4dddc84bc9402aa124c8 v4.8.0
22 22 c5a2b7d0e4bbdebc4a62d7b624befe375207b659 v4.9.0
23 23 d9aa3b27ac9f7e78359775c75fedf7bfece232f1 v4.9.1
24 24 4ba4d74981cec5d6b28b158f875a2540952c2f74 v4.10.0
25 25 0a6821cbd6b0b3c21503002f88800679fa35ab63 v4.10.1
26 26 434ad90ec8d621f4416074b84f6e9ce03964defb v4.10.2
27 27 68baee10e698da2724c6e0f698c03a6abb993bf2 v4.10.3
28 28 00821d3afd1dce3f4767cc353f84a17f7d5218a1 v4.10.4
29 29 22f6744ad8cc274311825f63f953e4dee2ea5cb9 v4.10.5
30 30 96eb24bea2f5f9258775245e3f09f6fa0a4dda01 v4.10.6
31 31 3121217a812c956d7dd5a5875821bd73e8002a32 v4.11.0
32 32 fa98b454715ac5b912f39e84af54345909a2a805 v4.11.1
33 33 3982abcfdcc229a723cebe52d3a9bcff10bba08e v4.11.2
34 34 33195f145db9172f0a8f1487e09207178a6ab065 v4.11.3
35 35 194c74f33e32bbae6fc4d71ec5a999cff3c13605 v4.11.4
36 36 8fbd8b0c3ddc2fa4ac9e4ca16942a03eb593df2d v4.11.5
37 37 f0609aa5d5d05a1ca2f97c3995542236131c9d8a v4.11.6
38 38 b5b30547d90d2e088472a70c84878f429ffbf40d v4.12.0
39 39 9072253aa8894d20c00b4a43dc61c2168c1eff94 v4.12.1
40 40 6a517543ea9ef9987d74371bd2a315eb0b232dc9 v4.12.2
41 41 7fc0731b024c3114be87865eda7ab621cc957e32 v4.12.3
42 42 6d531c0b068c6eda62dddceedc9f845ecb6feb6f v4.12.4
43 43 3d6bf2d81b1564830eb5e83396110d2a9a93eb1e v4.13.0
44 44 5468fc89e708bd90e413cd0d54350017abbdbc0e v4.13.1
45 45 610d621550521c314ee97b3d43473ac0bcf06fb8 v4.13.2
46 46 7dc62c090881fb5d03268141e71e0940d7c3295d v4.13.3
47 47 9151328c1c46b72ba6f00d7640d9141e75aa1ca2 v4.14.0
48 48 a47eeac5dfa41fa6779d90452affba4091c3ade8 v4.14.1
49 49 4b34ce0d2c3c10510626b3b65044939bb7a2cddf v4.15.0
50 14502561d22e6b70613674cd675ae9a604b7989f v4.15.1
@@ -1,126 +1,127 b''
1 1 .. _rhodecode-release-notes-ref:
2 2
3 3 Release Notes
4 4 =============
5 5
6 6 |RCE| 4.x Versions
7 7 ------------------
8 8
9 9 .. toctree::
10 10 :maxdepth: 1
11 11
12 release-notes-4.15.1.rst
12 13 release-notes-4.15.0.rst
13 14 release-notes-4.14.1.rst
14 15 release-notes-4.14.0.rst
15 16 release-notes-4.13.3.rst
16 17 release-notes-4.13.2.rst
17 18 release-notes-4.13.1.rst
18 19 release-notes-4.13.0.rst
19 20 release-notes-4.12.4.rst
20 21 release-notes-4.12.3.rst
21 22 release-notes-4.12.2.rst
22 23 release-notes-4.12.1.rst
23 24 release-notes-4.12.0.rst
24 25 release-notes-4.11.6.rst
25 26 release-notes-4.11.5.rst
26 27 release-notes-4.11.4.rst
27 28 release-notes-4.11.3.rst
28 29 release-notes-4.11.2.rst
29 30 release-notes-4.11.1.rst
30 31 release-notes-4.11.0.rst
31 32 release-notes-4.10.6.rst
32 33 release-notes-4.10.5.rst
33 34 release-notes-4.10.4.rst
34 35 release-notes-4.10.3.rst
35 36 release-notes-4.10.2.rst
36 37 release-notes-4.10.1.rst
37 38 release-notes-4.10.0.rst
38 39 release-notes-4.9.1.rst
39 40 release-notes-4.9.0.rst
40 41 release-notes-4.8.0.rst
41 42 release-notes-4.7.2.rst
42 43 release-notes-4.7.1.rst
43 44 release-notes-4.7.0.rst
44 45 release-notes-4.6.1.rst
45 46 release-notes-4.6.0.rst
46 47 release-notes-4.5.2.rst
47 48 release-notes-4.5.1.rst
48 49 release-notes-4.5.0.rst
49 50 release-notes-4.4.2.rst
50 51 release-notes-4.4.1.rst
51 52 release-notes-4.4.0.rst
52 53 release-notes-4.3.1.rst
53 54 release-notes-4.3.0.rst
54 55 release-notes-4.2.1.rst
55 56 release-notes-4.2.0.rst
56 57 release-notes-4.1.2.rst
57 58 release-notes-4.1.1.rst
58 59 release-notes-4.1.0.rst
59 60 release-notes-4.0.1.rst
60 61 release-notes-4.0.0.rst
61 62
62 63 |RCE| 3.x Versions
63 64 ------------------
64 65
65 66 .. toctree::
66 67 :maxdepth: 1
67 68
68 69 release-notes-3.8.4.rst
69 70 release-notes-3.8.3.rst
70 71 release-notes-3.8.2.rst
71 72 release-notes-3.8.1.rst
72 73 release-notes-3.8.0.rst
73 74 release-notes-3.7.1.rst
74 75 release-notes-3.7.0.rst
75 76 release-notes-3.6.1.rst
76 77 release-notes-3.6.0.rst
77 78 release-notes-3.5.2.rst
78 79 release-notes-3.5.1.rst
79 80 release-notes-3.5.0.rst
80 81 release-notes-3.4.1.rst
81 82 release-notes-3.4.0.rst
82 83 release-notes-3.3.4.rst
83 84 release-notes-3.3.3.rst
84 85 release-notes-3.3.2.rst
85 86 release-notes-3.3.1.rst
86 87 release-notes-3.3.0.rst
87 88 release-notes-3.2.3.rst
88 89 release-notes-3.2.2.rst
89 90 release-notes-3.2.1.rst
90 91 release-notes-3.2.0.rst
91 92 release-notes-3.1.1.rst
92 93 release-notes-3.1.0.rst
93 94 release-notes-3.0.2.rst
94 95 release-notes-3.0.1.rst
95 96 release-notes-3.0.0.rst
96 97
97 98 |RCE| 2.x Versions
98 99 ------------------
99 100
100 101 .. toctree::
101 102 :maxdepth: 1
102 103
103 104 release-notes-2.2.8.rst
104 105 release-notes-2.2.7.rst
105 106 release-notes-2.2.6.rst
106 107 release-notes-2.2.5.rst
107 108 release-notes-2.2.4.rst
108 109 release-notes-2.2.3.rst
109 110 release-notes-2.2.2.rst
110 111 release-notes-2.2.1.rst
111 112 release-notes-2.2.0.rst
112 113 release-notes-2.1.0.rst
113 114 release-notes-2.0.2.rst
114 115 release-notes-2.0.1.rst
115 116 release-notes-2.0.0.rst
116 117
117 118 |RCE| 1.x Versions
118 119 ------------------
119 120
120 121 .. toctree::
121 122 :maxdepth: 1
122 123
123 124 release-notes-1.7.2.rst
124 125 release-notes-1.7.1.rst
125 126 release-notes-1.7.0.rst
126 127 release-notes-1.6.0.rst
@@ -1,349 +1,368 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
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 20
21 21 import pytest
22 22
23 23 from rhodecode.model.db import User
24 24 from rhodecode.model.pull_request import PullRequestModel
25 25 from rhodecode.model.repo import RepoModel
26 26 from rhodecode.model.user import UserModel
27 27 from rhodecode.tests import TEST_USER_ADMIN_LOGIN, TEST_USER_REGULAR_LOGIN
28 28 from rhodecode.api.tests.utils import build_data, api_call, assert_error
29 29
30 30
31 31 @pytest.mark.usefixtures("testuser_api", "app")
32 32 class TestCreatePullRequestApi(object):
33 33 finalizers = []
34 34
35 35 def teardown_method(self, method):
36 36 if self.finalizers:
37 37 for finalizer in self.finalizers:
38 38 finalizer()
39 39 self.finalizers = []
40 40
41 41 def test_create_with_wrong_data(self):
42 42 required_data = {
43 43 'source_repo': 'tests/source_repo',
44 44 'target_repo': 'tests/target_repo',
45 45 'source_ref': 'branch:default:initial',
46 46 'target_ref': 'branch:default:new-feature',
47 47 }
48 48 for key in required_data:
49 49 data = required_data.copy()
50 50 data.pop(key)
51 51 id_, params = build_data(
52 52 self.apikey, 'create_pull_request', **data)
53 53 response = api_call(self.app, params)
54 54
55 55 expected = 'Missing non optional `{}` arg in JSON DATA'.format(key)
56 56 assert_error(id_, expected, given=response.body)
57 57
58 58 @pytest.mark.backends("git", "hg")
59 @pytest.mark.parametrize('source_ref', [
60 'bookmarg:default:initial'
61 ])
62 def test_create_with_wrong_refs_data(self, backend, source_ref):
63
64 data = self._prepare_data(backend)
65 data['source_ref'] = source_ref
66
67 id_, params = build_data(
68 self.apikey_regular, 'create_pull_request', **data)
69
70 response = api_call(self.app, params)
71
72 expected = "Ref `{}` type is not allowed. " \
73 "Only:['bookmark', 'book', 'tag', 'branch'] " \
74 "are possible.".format(source_ref)
75 assert_error(id_, expected, given=response.body)
76
77 @pytest.mark.backends("git", "hg")
59 78 def test_create_with_correct_data(self, backend):
60 79 data = self._prepare_data(backend)
61 80 RepoModel().revoke_user_permission(
62 81 self.source.repo_name, User.DEFAULT_USER)
63 82 id_, params = build_data(
64 83 self.apikey_regular, 'create_pull_request', **data)
65 84 response = api_call(self.app, params)
66 85 expected_message = "Created new pull request `{title}`".format(
67 86 title=data['title'])
68 87 result = response.json
69 88 assert result['error'] is None
70 89 assert result['result']['msg'] == expected_message
71 90 pull_request_id = result['result']['pull_request_id']
72 91 pull_request = PullRequestModel().get(pull_request_id)
73 92 assert pull_request.title == data['title']
74 93 assert pull_request.description == data['description']
75 94 assert pull_request.source_ref == data['source_ref']
76 95 assert pull_request.target_ref == data['target_ref']
77 96 assert pull_request.source_repo.repo_name == data['source_repo']
78 97 assert pull_request.target_repo.repo_name == data['target_repo']
79 98 assert pull_request.revisions == [self.commit_ids['change']]
80 99 assert len(pull_request.reviewers) == 1
81 100
82 101 @pytest.mark.backends("git", "hg")
83 102 def test_create_with_empty_description(self, backend):
84 103 data = self._prepare_data(backend)
85 104 data.pop('description')
86 105 id_, params = build_data(
87 106 self.apikey_regular, 'create_pull_request', **data)
88 107 response = api_call(self.app, params)
89 108 expected_message = "Created new pull request `{title}`".format(
90 109 title=data['title'])
91 110 result = response.json
92 111 assert result['error'] is None
93 112 assert result['result']['msg'] == expected_message
94 113 pull_request_id = result['result']['pull_request_id']
95 114 pull_request = PullRequestModel().get(pull_request_id)
96 115 assert pull_request.description == ''
97 116
98 117 @pytest.mark.backends("git", "hg")
99 118 def test_create_with_empty_title(self, backend):
100 119 data = self._prepare_data(backend)
101 120 data.pop('title')
102 121 id_, params = build_data(
103 122 self.apikey_regular, 'create_pull_request', **data)
104 123 response = api_call(self.app, params)
105 124 result = response.json
106 125 pull_request_id = result['result']['pull_request_id']
107 126 pull_request = PullRequestModel().get(pull_request_id)
108 127 data['ref'] = backend.default_branch_name
109 128 title = '{source_repo}#{ref} to {target_repo}'.format(**data)
110 129 assert pull_request.title == title
111 130
112 131 @pytest.mark.backends("git", "hg")
113 132 def test_create_with_reviewers_specified_by_names(
114 133 self, backend, no_notifications):
115 134 data = self._prepare_data(backend)
116 135 reviewers = [
117 136 {'username': TEST_USER_REGULAR_LOGIN,
118 137 'reasons': ['{} added manually'.format(TEST_USER_REGULAR_LOGIN)]},
119 138 {'username': TEST_USER_ADMIN_LOGIN,
120 139 'reasons': ['{} added manually'.format(TEST_USER_ADMIN_LOGIN)],
121 140 'mandatory': True},
122 141 ]
123 142 data['reviewers'] = reviewers
124 143
125 144 id_, params = build_data(
126 145 self.apikey_regular, 'create_pull_request', **data)
127 146 response = api_call(self.app, params)
128 147
129 148 expected_message = "Created new pull request `{title}`".format(
130 149 title=data['title'])
131 150 result = response.json
132 151 assert result['error'] is None
133 152 assert result['result']['msg'] == expected_message
134 153 pull_request_id = result['result']['pull_request_id']
135 154 pull_request = PullRequestModel().get(pull_request_id)
136 155
137 156 actual_reviewers = []
138 157 for rev in pull_request.reviewers:
139 158 entry = {
140 159 'username': rev.user.username,
141 160 'reasons': rev.reasons,
142 161 }
143 162 if rev.mandatory:
144 163 entry['mandatory'] = rev.mandatory
145 164 actual_reviewers.append(entry)
146 165
147 166 owner_username = pull_request.target_repo.user.username
148 167 for spec_reviewer in reviewers[::]:
149 168 # default reviewer will be added who is an owner of the repo
150 169 # this get's overridden by a add owner to reviewers rule
151 170 if spec_reviewer['username'] == owner_username:
152 171 spec_reviewer['reasons'] = [u'Default reviewer', u'Repository owner']
153 172 # since owner is more important, we don't inherit mandatory flag
154 173 del spec_reviewer['mandatory']
155 174
156 175 assert sorted(actual_reviewers, key=lambda e: e['username']) \
157 176 == sorted(reviewers, key=lambda e: e['username'])
158 177
159 178 @pytest.mark.backends("git", "hg")
160 179 def test_create_with_reviewers_specified_by_ids(
161 180 self, backend, no_notifications):
162 181 data = self._prepare_data(backend)
163 182 reviewers = [
164 183 {'username': UserModel().get_by_username(
165 184 TEST_USER_REGULAR_LOGIN).user_id,
166 185 'reasons': ['added manually']},
167 186 {'username': UserModel().get_by_username(
168 187 TEST_USER_ADMIN_LOGIN).user_id,
169 188 'reasons': ['added manually']},
170 189 ]
171 190
172 191 data['reviewers'] = reviewers
173 192 id_, params = build_data(
174 193 self.apikey_regular, 'create_pull_request', **data)
175 194 response = api_call(self.app, params)
176 195
177 196 expected_message = "Created new pull request `{title}`".format(
178 197 title=data['title'])
179 198 result = response.json
180 199 assert result['error'] is None
181 200 assert result['result']['msg'] == expected_message
182 201 pull_request_id = result['result']['pull_request_id']
183 202 pull_request = PullRequestModel().get(pull_request_id)
184 203
185 204 actual_reviewers = []
186 205 for rev in pull_request.reviewers:
187 206 entry = {
188 207 'username': rev.user.user_id,
189 208 'reasons': rev.reasons,
190 209 }
191 210 if rev.mandatory:
192 211 entry['mandatory'] = rev.mandatory
193 212 actual_reviewers.append(entry)
194 213
195 214 owner_user_id = pull_request.target_repo.user.user_id
196 215 for spec_reviewer in reviewers[::]:
197 216 # default reviewer will be added who is an owner of the repo
198 217 # this get's overridden by a add owner to reviewers rule
199 218 if spec_reviewer['username'] == owner_user_id:
200 219 spec_reviewer['reasons'] = [u'Default reviewer', u'Repository owner']
201 220
202 221 assert sorted(actual_reviewers, key=lambda e: e['username']) \
203 222 == sorted(reviewers, key=lambda e: e['username'])
204 223
205 224 @pytest.mark.backends("git", "hg")
206 225 def test_create_fails_when_the_reviewer_is_not_found(self, backend):
207 226 data = self._prepare_data(backend)
208 227 data['reviewers'] = [{'username': 'somebody'}]
209 228 id_, params = build_data(
210 229 self.apikey_regular, 'create_pull_request', **data)
211 230 response = api_call(self.app, params)
212 231 expected_message = 'user `somebody` does not exist'
213 232 assert_error(id_, expected_message, given=response.body)
214 233
215 234 @pytest.mark.backends("git", "hg")
216 235 def test_cannot_create_with_reviewers_in_wrong_format(self, backend):
217 236 data = self._prepare_data(backend)
218 237 reviewers = ','.join([TEST_USER_REGULAR_LOGIN, TEST_USER_ADMIN_LOGIN])
219 238 data['reviewers'] = reviewers
220 239 id_, params = build_data(
221 240 self.apikey_regular, 'create_pull_request', **data)
222 241 response = api_call(self.app, params)
223 242 expected_message = {u'': '"test_regular,test_admin" is not iterable'}
224 243 assert_error(id_, expected_message, given=response.body)
225 244
226 245 @pytest.mark.backends("git", "hg")
227 246 def test_create_with_no_commit_hashes(self, backend):
228 247 data = self._prepare_data(backend)
229 248 expected_source_ref = data['source_ref']
230 249 expected_target_ref = data['target_ref']
231 250 data['source_ref'] = 'branch:{}'.format(backend.default_branch_name)
232 251 data['target_ref'] = 'branch:{}'.format(backend.default_branch_name)
233 252 id_, params = build_data(
234 253 self.apikey_regular, 'create_pull_request', **data)
235 254 response = api_call(self.app, params)
236 255 expected_message = "Created new pull request `{title}`".format(
237 256 title=data['title'])
238 257 result = response.json
239 258 assert result['result']['msg'] == expected_message
240 259 pull_request_id = result['result']['pull_request_id']
241 260 pull_request = PullRequestModel().get(pull_request_id)
242 261 assert pull_request.source_ref == expected_source_ref
243 262 assert pull_request.target_ref == expected_target_ref
244 263
245 264 @pytest.mark.backends("git", "hg")
246 265 @pytest.mark.parametrize("data_key", ["source_repo", "target_repo"])
247 266 def test_create_fails_with_wrong_repo(self, backend, data_key):
248 267 repo_name = 'fake-repo'
249 268 data = self._prepare_data(backend)
250 269 data[data_key] = repo_name
251 270 id_, params = build_data(
252 271 self.apikey_regular, 'create_pull_request', **data)
253 272 response = api_call(self.app, params)
254 273 expected_message = 'repository `{}` does not exist'.format(repo_name)
255 274 assert_error(id_, expected_message, given=response.body)
256 275
257 276 @pytest.mark.backends("git", "hg")
258 277 @pytest.mark.parametrize("data_key", ["source_ref", "target_ref"])
259 278 def test_create_fails_with_non_existing_branch(self, backend, data_key):
260 279 branch_name = 'test-branch'
261 280 data = self._prepare_data(backend)
262 281 data[data_key] = "branch:{}".format(branch_name)
263 282 id_, params = build_data(
264 283 self.apikey_regular, 'create_pull_request', **data)
265 284 response = api_call(self.app, params)
266 285 expected_message = 'The specified value:{type}:`{name}` ' \
267 286 'does not exist, or is not allowed.'.format(type='branch',
268 287 name=branch_name)
269 288 assert_error(id_, expected_message, given=response.body)
270 289
271 290 @pytest.mark.backends("git", "hg")
272 291 @pytest.mark.parametrize("data_key", ["source_ref", "target_ref"])
273 292 def test_create_fails_with_ref_in_a_wrong_format(self, backend, data_key):
274 293 data = self._prepare_data(backend)
275 294 ref = 'stange-ref'
276 295 data[data_key] = ref
277 296 id_, params = build_data(
278 297 self.apikey_regular, 'create_pull_request', **data)
279 298 response = api_call(self.app, params)
280 299 expected_message = (
281 300 'Ref `{ref}` given in a wrong format. Please check the API'
282 301 ' documentation for more details'.format(ref=ref))
283 302 assert_error(id_, expected_message, given=response.body)
284 303
285 304 @pytest.mark.backends("git", "hg")
286 305 @pytest.mark.parametrize("data_key", ["source_ref", "target_ref"])
287 306 def test_create_fails_with_non_existing_ref(self, backend, data_key):
288 307 commit_id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa10'
289 308 ref = self._get_full_ref(backend, commit_id)
290 309 data = self._prepare_data(backend)
291 310 data[data_key] = ref
292 311 id_, params = build_data(
293 312 self.apikey_regular, 'create_pull_request', **data)
294 313 response = api_call(self.app, params)
295 314 expected_message = 'Ref `{}` does not exist'.format(ref)
296 315 assert_error(id_, expected_message, given=response.body)
297 316
298 317 @pytest.mark.backends("git", "hg")
299 318 def test_create_fails_when_no_revisions(self, backend):
300 319 data = self._prepare_data(backend, source_head='initial')
301 320 id_, params = build_data(
302 321 self.apikey_regular, 'create_pull_request', **data)
303 322 response = api_call(self.app, params)
304 323 expected_message = 'no commits found'
305 324 assert_error(id_, expected_message, given=response.body)
306 325
307 326 @pytest.mark.backends("git", "hg")
308 327 def test_create_fails_when_no_permissions(self, backend):
309 328 data = self._prepare_data(backend)
310 329 RepoModel().revoke_user_permission(
311 330 self.source.repo_name, self.test_user)
312 331 RepoModel().revoke_user_permission(
313 332 self.source.repo_name, User.DEFAULT_USER)
314 333
315 334 id_, params = build_data(
316 335 self.apikey_regular, 'create_pull_request', **data)
317 336 response = api_call(self.app, params)
318 337 expected_message = 'repository `{}` does not exist'.format(
319 338 self.source.repo_name)
320 339 assert_error(id_, expected_message, given=response.body)
321 340
322 341 def _prepare_data(
323 342 self, backend, source_head='change', target_head='initial'):
324 343 commits = [
325 344 {'message': 'initial'},
326 345 {'message': 'change'},
327 346 {'message': 'new-feature', 'parents': ['initial']},
328 347 ]
329 348 self.commit_ids = backend.create_master_repo(commits)
330 349 self.source = backend.create_repo(heads=[source_head])
331 350 self.target = backend.create_repo(heads=[target_head])
332 351
333 352 data = {
334 353 'source_repo': self.source.repo_name,
335 354 'target_repo': self.target.repo_name,
336 355 'source_ref': self._get_full_ref(
337 356 backend, self.commit_ids[source_head]),
338 357 'target_ref': self._get_full_ref(
339 358 backend, self.commit_ids[target_head]),
340 359 'title': 'Test PR 1',
341 360 'description': 'Test'
342 361 }
343 362 RepoModel().grant_user_permission(
344 363 self.source.repo_name, self.TEST_USER_LOGIN, 'repository.read')
345 364 return data
346 365
347 366 def _get_full_ref(self, backend, commit_id):
348 367 return 'branch:{branch}:{commit_id}'.format(
349 368 branch=backend.default_branch_name, commit_id=commit_id)
@@ -1,295 +1,295 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
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 20
21 21
22 22 import pytest
23 23 from mock import Mock, patch
24 24
25 25 from rhodecode.api import utils
26 26 from rhodecode.api import JSONRPCError
27 27 from rhodecode.lib.vcs.exceptions import RepositoryError
28 28
29 29
30 30 class TestGetCommitOrError(object):
31 31 def setup(self):
32 32 self.commit_hash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa10'
33 33
34 34 @pytest.mark.parametrize("ref", ['ref', '12345', 'a:b:c:d', 'branch:name'])
35 35 def test_ref_cannot_be_parsed(self, ref):
36 36 repo = Mock()
37 37 with pytest.raises(JSONRPCError) as excinfo:
38 38 utils.get_commit_or_error(ref, repo)
39 39 expected_message = (
40 40 'Ref `{ref}` given in a wrong format. Please check the API'
41 41 ' documentation for more details'.format(ref=ref)
42 42 )
43 43 assert excinfo.value.message == expected_message
44 44
45 45 def test_success_with_hash_specified(self):
46 46 repo = Mock()
47 47 ref_type = 'branch'
48 48 ref = '{}:master:{}'.format(ref_type, self.commit_hash)
49 49
50 50 with patch('rhodecode.api.utils.get_commit_from_ref_name') as get_commit:
51 51 result = utils.get_commit_or_error(ref, repo)
52 52 get_commit.assert_called_once_with(
53 53 repo, self.commit_hash)
54 54 assert result == get_commit()
55 55
56 56 def test_raises_an_error_when_commit_not_found(self):
57 57 repo = Mock()
58 58 ref = 'branch:master:{}'.format(self.commit_hash)
59 59
60 60 with patch('rhodecode.api.utils.get_commit_from_ref_name') as get_commit:
61 61 get_commit.side_effect = RepositoryError('Commit not found')
62 62 with pytest.raises(JSONRPCError) as excinfo:
63 63 utils.get_commit_or_error(ref, repo)
64 64 expected_message = 'Ref `{}` does not exist'.format(ref)
65 65 assert excinfo.value.message == expected_message
66 66
67 67
68 68 class TestResolveRefOrError(object):
69 69 def setup(self):
70 70 self.commit_hash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa10'
71 71
72 72 def test_success_with_no_hash_specified(self):
73 73 repo = Mock()
74 74 ref_type = 'branch'
75 75 ref_name = 'master'
76 76 ref = '{}:{}'.format(ref_type, ref_name)
77 77
78 78 with patch('rhodecode.api.utils._get_ref_hash') \
79 79 as _get_ref_hash:
80 80 _get_ref_hash.return_value = self.commit_hash
81 81 result = utils.resolve_ref_or_error(ref, repo)
82 82 _get_ref_hash.assert_called_once_with(repo, ref_type, ref_name)
83 83 assert result == '{}:{}'.format(ref, self.commit_hash)
84 84
85 85 def test_non_supported_refs(self):
86 86 repo = Mock()
87 ref = 'ancestor:ref'
87 ref = 'bookmark:ref'
88 88 with pytest.raises(JSONRPCError) as excinfo:
89 89 utils.resolve_ref_or_error(ref, repo)
90 90 expected_message = (
91 'The specified value:ancestor:`ref` does not exist, or is not allowed.')
91 'The specified value:bookmark:`ref` does not exist, or is not allowed.')
92 92 assert excinfo.value.message == expected_message
93 93
94 94 def test_branch_is_not_found(self):
95 95 repo = Mock()
96 96 ref = 'branch:non-existing-one'
97 97 with patch('rhodecode.api.utils._get_ref_hash')\
98 98 as _get_ref_hash:
99 99 _get_ref_hash.side_effect = KeyError()
100 100 with pytest.raises(JSONRPCError) as excinfo:
101 101 utils.resolve_ref_or_error(ref, repo)
102 102 expected_message = (
103 103 'The specified value:branch:`non-existing-one` does not exist, or is not allowed.')
104 104 assert excinfo.value.message == expected_message
105 105
106 106 def test_bookmark_is_not_found(self):
107 107 repo = Mock()
108 108 ref = 'bookmark:non-existing-one'
109 109 with patch('rhodecode.api.utils._get_ref_hash')\
110 110 as _get_ref_hash:
111 111 _get_ref_hash.side_effect = KeyError()
112 112 with pytest.raises(JSONRPCError) as excinfo:
113 113 utils.resolve_ref_or_error(ref, repo)
114 114 expected_message = (
115 115 'The specified value:bookmark:`non-existing-one` does not exist, or is not allowed.')
116 116 assert excinfo.value.message == expected_message
117 117
118 118 @pytest.mark.parametrize("ref", ['ref', '12345', 'a:b:c:d'])
119 119 def test_ref_cannot_be_parsed(self, ref):
120 120 repo = Mock()
121 121 with pytest.raises(JSONRPCError) as excinfo:
122 122 utils.resolve_ref_or_error(ref, repo)
123 123 expected_message = (
124 124 'Ref `{ref}` given in a wrong format. Please check the API'
125 125 ' documentation for more details'.format(ref=ref)
126 126 )
127 127 assert excinfo.value.message == expected_message
128 128
129 129
130 130 class TestGetRefHash(object):
131 131 def setup(self):
132 132 self.commit_hash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa10'
133 133 self.bookmark_name = 'test-bookmark'
134 134
135 135 @pytest.mark.parametrize("alias, branch_name", [
136 136 ("git", "master"),
137 137 ("hg", "default")
138 138 ])
139 139 def test_returns_hash_by_branch_name(self, alias, branch_name):
140 140 with patch('rhodecode.model.db.Repository') as repo:
141 141 repo.scm_instance().alias = alias
142 142 repo.scm_instance().branches = {branch_name: self.commit_hash}
143 143 result_hash = utils._get_ref_hash(repo, 'branch', branch_name)
144 144 assert result_hash == self.commit_hash
145 145
146 146 @pytest.mark.parametrize("alias, branch_name", [
147 147 ("git", "master"),
148 148 ("hg", "default")
149 149 ])
150 150 def test_raises_error_when_branch_is_not_found(self, alias, branch_name):
151 151 with patch('rhodecode.model.db.Repository') as repo:
152 152 repo.scm_instance().alias = alias
153 153 repo.scm_instance().branches = {}
154 154 with pytest.raises(KeyError):
155 155 utils._get_ref_hash(repo, 'branch', branch_name)
156 156
157 157 def test_returns_hash_when_bookmark_is_specified_for_hg(self):
158 158 with patch('rhodecode.model.db.Repository') as repo:
159 159 repo.scm_instance().alias = 'hg'
160 160 repo.scm_instance().bookmarks = {
161 161 self.bookmark_name: self.commit_hash}
162 162 result_hash = utils._get_ref_hash(
163 163 repo, 'bookmark', self.bookmark_name)
164 164 assert result_hash == self.commit_hash
165 165
166 166 def test_raises_error_when_bookmark_is_not_found_in_hg_repo(self):
167 167 with patch('rhodecode.model.db.Repository') as repo:
168 168 repo.scm_instance().alias = 'hg'
169 169 repo.scm_instance().bookmarks = {}
170 170 with pytest.raises(KeyError):
171 171 utils._get_ref_hash(repo, 'bookmark', self.bookmark_name)
172 172
173 173 def test_raises_error_when_bookmark_is_specified_for_git(self):
174 174 with patch('rhodecode.model.db.Repository') as repo:
175 175 repo.scm_instance().alias = 'git'
176 176 repo.scm_instance().bookmarks = {
177 177 self.bookmark_name: self.commit_hash}
178 178 with pytest.raises(ValueError):
179 179 utils._get_ref_hash(repo, 'bookmark', self.bookmark_name)
180 180
181 181
182 182 class TestUserByNameOrError(object):
183 183 def test_user_found_by_id(self):
184 184 fake_user = Mock(id=123)
185 185
186 186 patcher = patch('rhodecode.model.user.UserModel.get_user')
187 187 with patcher as get_user:
188 188 get_user.return_value = fake_user
189 189
190 190 patcher = patch('rhodecode.model.user.UserModel.get_by_username')
191 191 with patcher as get_by_username:
192 192 result = utils.get_user_or_error(123)
193 193 assert result == fake_user
194 194
195 195 def test_user_not_found_by_id_as_str(self):
196 196 fake_user = Mock(id=123)
197 197
198 198 patcher = patch('rhodecode.model.user.UserModel.get_user')
199 199 with patcher as get_user:
200 200 get_user.return_value = fake_user
201 201 patcher = patch('rhodecode.model.user.UserModel.get_by_username')
202 202 with patcher as get_by_username:
203 203 get_by_username.return_value = None
204 204
205 205 with pytest.raises(JSONRPCError):
206 206 utils.get_user_or_error('123')
207 207
208 208 def test_user_found_by_name(self):
209 209 fake_user = Mock(id=123)
210 210
211 211 patcher = patch('rhodecode.model.user.UserModel.get_user')
212 212 with patcher as get_user:
213 213 get_user.return_value = None
214 214
215 215 patcher = patch('rhodecode.model.user.UserModel.get_by_username')
216 216 with patcher as get_by_username:
217 217 get_by_username.return_value = fake_user
218 218
219 219 result = utils.get_user_or_error('test')
220 220 assert result == fake_user
221 221
222 222 def test_user_not_found_by_id(self):
223 223 patcher = patch('rhodecode.model.user.UserModel.get_user')
224 224 with patcher as get_user:
225 225 get_user.return_value = None
226 226 patcher = patch('rhodecode.model.user.UserModel.get_by_username')
227 227 with patcher as get_by_username:
228 228 get_by_username.return_value = None
229 229
230 230 with pytest.raises(JSONRPCError) as excinfo:
231 231 utils.get_user_or_error(123)
232 232
233 233 expected_message = 'user `123` does not exist'
234 234 assert excinfo.value.message == expected_message
235 235
236 236 def test_user_not_found_by_name(self):
237 237 patcher = patch('rhodecode.model.user.UserModel.get_by_username')
238 238 with patcher as get_by_username:
239 239 get_by_username.return_value = None
240 240 with pytest.raises(JSONRPCError) as excinfo:
241 241 utils.get_user_or_error('test')
242 242
243 243 expected_message = 'user `test` does not exist'
244 244 assert excinfo.value.message == expected_message
245 245
246 246
247 247 class TestGetCommitDict(object):
248 248 @pytest.mark.parametrize('filename, expected', [
249 249 (b'sp\xc3\xa4cial', u'sp\xe4cial'),
250 250 (b'sp\xa4cial', u'sp\ufffdcial'),
251 251 ])
252 252 def test_decodes_filenames_to_unicode(self, filename, expected):
253 253 result = utils._get_commit_dict(filename=filename, op='A')
254 254 assert result['filename'] == expected
255 255
256 256
257 257 class TestRepoAccess(object):
258 258 def setup_method(self, method):
259 259
260 260 self.admin_perm_patch = patch(
261 261 'rhodecode.api.utils.HasPermissionAnyApi')
262 262 self.repo_perm_patch = patch(
263 263 'rhodecode.api.utils.HasRepoPermissionAnyApi')
264 264
265 265 def test_has_superadmin_permission_checks_for_admin(self):
266 266 admin_mock = Mock()
267 267 with self.admin_perm_patch as amock:
268 268 amock.return_value = admin_mock
269 269 assert utils.has_superadmin_permission('fake_user')
270 270 amock.assert_called_once_with('hg.admin')
271 271
272 272 admin_mock.assert_called_once_with(user='fake_user')
273 273
274 274 def test_has_repo_permissions_checks_for_repo_access(self):
275 275 repo_mock = Mock()
276 276 fake_repo = Mock()
277 277 with self.repo_perm_patch as rmock:
278 278 rmock.return_value = repo_mock
279 279 assert utils.validate_repo_permissions(
280 280 'fake_user', 'fake_repo_id', fake_repo,
281 281 ['perm1', 'perm2'])
282 282 rmock.assert_called_once_with(*['perm1', 'perm2'])
283 283
284 284 repo_mock.assert_called_once_with(
285 285 user='fake_user', repo_name=fake_repo.repo_name)
286 286
287 287 def test_has_repo_permissions_raises_not_found(self):
288 288 repo_mock = Mock(return_value=False)
289 289 fake_repo = Mock()
290 290 with self.repo_perm_patch as rmock:
291 291 rmock.return_value = repo_mock
292 292 with pytest.raises(JSONRPCError) as excinfo:
293 293 utils.validate_repo_permissions(
294 294 'fake_user', 'fake_repo_id', fake_repo, 'perms')
295 295 assert 'fake_repo_id' in excinfo
@@ -1,441 +1,449 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
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 20
21 21 """
22 22 JSON RPC utils
23 23 """
24 24
25 25 import collections
26 26 import logging
27 27
28 28 from rhodecode.api.exc import JSONRPCError
29 29 from rhodecode.lib.auth import (
30 30 HasPermissionAnyApi, HasRepoPermissionAnyApi, HasRepoGroupPermissionAnyApi)
31 31 from rhodecode.lib.utils import safe_unicode
32 32 from rhodecode.lib.vcs.exceptions import RepositoryError
33 33 from rhodecode.lib.view_utils import get_commit_from_ref_name
34 34 from rhodecode.lib.utils2 import str2bool
35 35
36 36 log = logging.getLogger(__name__)
37 37
38 38
39 39 class OAttr(object):
40 40 """
41 41 Special Option that defines other attribute, and can default to them
42 42
43 43 Example::
44 44
45 45 def test(apiuser, userid=Optional(OAttr('apiuser')):
46 46 user = Optional.extract(userid, evaluate_locals=local())
47 47 #if we pass in userid, we get it, else it will default to apiuser
48 48 #attribute
49 49 """
50 50
51 51 def __init__(self, attr_name):
52 52 self.attr_name = attr_name
53 53
54 54 def __repr__(self):
55 55 return '<OptionalAttr:%s>' % self.attr_name
56 56
57 57 def __call__(self):
58 58 return self
59 59
60 60
61 61 class Optional(object):
62 62 """
63 63 Defines an optional parameter::
64 64
65 65 param = param.getval() if isinstance(param, Optional) else param
66 66 param = param() if isinstance(param, Optional) else param
67 67
68 68 is equivalent of::
69 69
70 70 param = Optional.extract(param)
71 71
72 72 """
73 73
74 74 def __init__(self, type_):
75 75 self.type_ = type_
76 76
77 77 def __repr__(self):
78 78 return '<Optional:%s>' % self.type_.__repr__()
79 79
80 80 def __call__(self):
81 81 return self.getval()
82 82
83 83 def getval(self, evaluate_locals=None):
84 84 """
85 85 returns value from this Optional instance
86 86 """
87 87 if isinstance(self.type_, OAttr):
88 88 param_name = self.type_.attr_name
89 89 if evaluate_locals:
90 90 return evaluate_locals[param_name]
91 91 # use params name
92 92 return param_name
93 93 return self.type_
94 94
95 95 @classmethod
96 96 def extract(cls, val, evaluate_locals=None, binary=None):
97 97 """
98 98 Extracts value from Optional() instance
99 99
100 100 :param val:
101 101 :return: original value if it's not Optional instance else
102 102 value of instance
103 103 """
104 104 if isinstance(val, cls):
105 105 val = val.getval(evaluate_locals)
106 106
107 107 if binary:
108 108 val = str2bool(val)
109 109
110 110 return val
111 111
112 112
113 113 def parse_args(cli_args, key_prefix=''):
114 114 from rhodecode.lib.utils2 import (escape_split)
115 115 kwargs = collections.defaultdict(dict)
116 116 for el in escape_split(cli_args, ','):
117 117 kv = escape_split(el, '=', 1)
118 118 if len(kv) == 2:
119 119 k, v = kv
120 120 kwargs[key_prefix + k] = v
121 121 return kwargs
122 122
123 123
124 124 def get_origin(obj):
125 125 """
126 126 Get origin of permission from object.
127 127
128 128 :param obj:
129 129 """
130 130 origin = 'permission'
131 131
132 132 if getattr(obj, 'owner_row', '') and getattr(obj, 'admin_row', ''):
133 133 # admin and owner case, maybe we should use dual string ?
134 134 origin = 'owner'
135 135 elif getattr(obj, 'owner_row', ''):
136 136 origin = 'owner'
137 137 elif getattr(obj, 'admin_row', ''):
138 138 origin = 'super-admin'
139 139 return origin
140 140
141 141
142 142 def store_update(updates, attr, name):
143 143 """
144 144 Stores param in updates dict if it's not instance of Optional
145 145 allows easy updates of passed in params
146 146 """
147 147 if not isinstance(attr, Optional):
148 148 updates[name] = attr
149 149
150 150
151 151 def has_superadmin_permission(apiuser):
152 152 """
153 153 Return True if apiuser is admin or return False
154 154
155 155 :param apiuser:
156 156 """
157 157 if HasPermissionAnyApi('hg.admin')(user=apiuser):
158 158 return True
159 159 return False
160 160
161 161
162 162 def validate_repo_permissions(apiuser, repoid, repo, perms):
163 163 """
164 164 Raise JsonRPCError if apiuser is not authorized or return True
165 165
166 166 :param apiuser:
167 167 :param repoid:
168 168 :param repo:
169 169 :param perms:
170 170 """
171 171 if not HasRepoPermissionAnyApi(*perms)(
172 172 user=apiuser, repo_name=repo.repo_name):
173 173 raise JSONRPCError(
174 174 'repository `%s` does not exist' % repoid)
175 175
176 176 return True
177 177
178 178
179 179 def validate_repo_group_permissions(apiuser, repogroupid, repo_group, perms):
180 180 """
181 181 Raise JsonRPCError if apiuser is not authorized or return True
182 182
183 183 :param apiuser:
184 184 :param repogroupid: just the id of repository group
185 185 :param repo_group: instance of repo_group
186 186 :param perms:
187 187 """
188 188 if not HasRepoGroupPermissionAnyApi(*perms)(
189 189 user=apiuser, group_name=repo_group.group_name):
190 190 raise JSONRPCError(
191 191 'repository group `%s` does not exist' % repogroupid)
192 192
193 193 return True
194 194
195 195
196 196 def validate_set_owner_permissions(apiuser, owner):
197 197 if isinstance(owner, Optional):
198 198 owner = get_user_or_error(apiuser.user_id)
199 199 else:
200 200 if has_superadmin_permission(apiuser):
201 201 owner = get_user_or_error(owner)
202 202 else:
203 203 # forbid setting owner for non-admins
204 204 raise JSONRPCError(
205 205 'Only RhodeCode super-admin can specify `owner` param')
206 206 return owner
207 207
208 208
209 209 def get_user_or_error(userid):
210 210 """
211 211 Get user by id or name or return JsonRPCError if not found
212 212
213 213 :param userid:
214 214 """
215 215 from rhodecode.model.user import UserModel
216 216 user_model = UserModel()
217 217
218 218 if isinstance(userid, (int, long)):
219 219 try:
220 220 user = user_model.get_user(userid)
221 221 except ValueError:
222 222 user = None
223 223 else:
224 224 user = user_model.get_by_username(userid)
225 225
226 226 if user is None:
227 227 raise JSONRPCError(
228 228 'user `%s` does not exist' % (userid,))
229 229 return user
230 230
231 231
232 232 def get_repo_or_error(repoid):
233 233 """
234 234 Get repo by id or name or return JsonRPCError if not found
235 235
236 236 :param repoid:
237 237 """
238 238 from rhodecode.model.repo import RepoModel
239 239 repo_model = RepoModel()
240 240
241 241 if isinstance(repoid, (int, long)):
242 242 try:
243 243 repo = repo_model.get_repo(repoid)
244 244 except ValueError:
245 245 repo = None
246 246 else:
247 247 repo = repo_model.get_by_repo_name(repoid)
248 248
249 249 if repo is None:
250 250 raise JSONRPCError(
251 251 'repository `%s` does not exist' % (repoid,))
252 252 return repo
253 253
254 254
255 255 def get_repo_group_or_error(repogroupid):
256 256 """
257 257 Get repo group by id or name or return JsonRPCError if not found
258 258
259 259 :param repogroupid:
260 260 """
261 261 from rhodecode.model.repo_group import RepoGroupModel
262 262 repo_group_model = RepoGroupModel()
263 263
264 264 if isinstance(repogroupid, (int, long)):
265 265 try:
266 266 repo_group = repo_group_model._get_repo_group(repogroupid)
267 267 except ValueError:
268 268 repo_group = None
269 269 else:
270 270 repo_group = repo_group_model.get_by_group_name(repogroupid)
271 271
272 272 if repo_group is None:
273 273 raise JSONRPCError(
274 274 'repository group `%s` does not exist' % (repogroupid,))
275 275 return repo_group
276 276
277 277
278 278 def get_user_group_or_error(usergroupid):
279 279 """
280 280 Get user group by id or name or return JsonRPCError if not found
281 281
282 282 :param usergroupid:
283 283 """
284 284 from rhodecode.model.user_group import UserGroupModel
285 285 user_group_model = UserGroupModel()
286 286
287 287 if isinstance(usergroupid, (int, long)):
288 288 try:
289 289 user_group = user_group_model.get_group(usergroupid)
290 290 except ValueError:
291 291 user_group = None
292 292 else:
293 293 user_group = user_group_model.get_by_name(usergroupid)
294 294
295 295 if user_group is None:
296 296 raise JSONRPCError(
297 297 'user group `%s` does not exist' % (usergroupid,))
298 298 return user_group
299 299
300 300
301 301 def get_perm_or_error(permid, prefix=None):
302 302 """
303 303 Get permission by id or name or return JsonRPCError if not found
304 304
305 305 :param permid:
306 306 """
307 307 from rhodecode.model.permission import PermissionModel
308 308
309 309 perm = PermissionModel.cls.get_by_key(permid)
310 310 if perm is None:
311 311 raise JSONRPCError('permission `%s` does not exist' % (permid,))
312 312 if prefix:
313 313 if not perm.permission_name.startswith(prefix):
314 314 raise JSONRPCError('permission `%s` is invalid, '
315 315 'should start with %s' % (permid, prefix))
316 316 return perm
317 317
318 318
319 319 def get_gist_or_error(gistid):
320 320 """
321 321 Get gist by id or gist_access_id or return JsonRPCError if not found
322 322
323 323 :param gistid:
324 324 """
325 325 from rhodecode.model.gist import GistModel
326 326
327 327 gist = GistModel.cls.get_by_access_id(gistid)
328 328 if gist is None:
329 329 raise JSONRPCError('gist `%s` does not exist' % (gistid,))
330 330 return gist
331 331
332 332
333 333 def get_pull_request_or_error(pullrequestid):
334 334 """
335 335 Get pull request by id or return JsonRPCError if not found
336 336
337 337 :param pullrequestid:
338 338 """
339 339 from rhodecode.model.pull_request import PullRequestModel
340 340
341 341 try:
342 342 pull_request = PullRequestModel().get(int(pullrequestid))
343 343 except ValueError:
344 344 raise JSONRPCError('pullrequestid must be an integer')
345 345 if not pull_request:
346 346 raise JSONRPCError('pull request `%s` does not exist' % (
347 347 pullrequestid,))
348 348 return pull_request
349 349
350 350
351 351 def build_commit_data(commit, detail_level):
352 352 parsed_diff = []
353 353 if detail_level == 'extended':
354 354 for f in commit.added:
355 355 parsed_diff.append(_get_commit_dict(filename=f.path, op='A'))
356 356 for f in commit.changed:
357 357 parsed_diff.append(_get_commit_dict(filename=f.path, op='M'))
358 358 for f in commit.removed:
359 359 parsed_diff.append(_get_commit_dict(filename=f.path, op='D'))
360 360
361 361 elif detail_level == 'full':
362 362 from rhodecode.lib.diffs import DiffProcessor
363 363 diff_processor = DiffProcessor(commit.diff())
364 364 for dp in diff_processor.prepare():
365 365 del dp['stats']['ops']
366 366 _stats = dp['stats']
367 367 parsed_diff.append(_get_commit_dict(
368 368 filename=dp['filename'], op=dp['operation'],
369 369 new_revision=dp['new_revision'],
370 370 old_revision=dp['old_revision'],
371 371 raw_diff=dp['raw_diff'], stats=_stats))
372 372
373 373 return parsed_diff
374 374
375 375
376 376 def get_commit_or_error(ref, repo):
377 377 try:
378 378 ref_type, _, ref_hash = ref.split(':')
379 379 except ValueError:
380 380 raise JSONRPCError(
381 381 'Ref `{ref}` given in a wrong format. Please check the API'
382 382 ' documentation for more details'.format(ref=ref))
383 383 try:
384 384 # TODO: dan: refactor this to use repo.scm_instance().get_commit()
385 385 # once get_commit supports ref_types
386 386 return get_commit_from_ref_name(repo, ref_hash)
387 387 except RepositoryError:
388 388 raise JSONRPCError('Ref `{ref}` does not exist'.format(ref=ref))
389 389
390 390
391 def resolve_ref_or_error(ref, repo):
391 def _get_ref_hash(repo, type_, name):
392 vcs_repo = repo.scm_instance()
393 if type_ in ['branch'] and vcs_repo.alias in ('hg', 'git'):
394 return vcs_repo.branches[name]
395 elif type_ in ['bookmark', 'book'] and vcs_repo.alias == 'hg':
396 return vcs_repo.bookmarks[name]
397 else:
398 raise ValueError()
399
400
401 def resolve_ref_or_error(ref, repo, allowed_ref_types=None):
402 allowed_ref_types = allowed_ref_types or ['bookmark', 'book', 'tag', 'branch']
403
392 404 def _parse_ref(type_, name, hash_=None):
393 405 return type_, name, hash_
394 406
395 407 try:
396 408 ref_type, ref_name, ref_hash = _parse_ref(*ref.split(':'))
397 409 except TypeError:
398 410 raise JSONRPCError(
399 411 'Ref `{ref}` given in a wrong format. Please check the API'
400 412 ' documentation for more details'.format(ref=ref))
401 413
414 if ref_type not in allowed_ref_types:
415 raise JSONRPCError(
416 'Ref `{ref}` type is not allowed. '
417 'Only:{allowed_refs} are possible.'.format(
418 ref=ref, allowed_refs=allowed_ref_types))
419
402 420 try:
403 421 ref_hash = ref_hash or _get_ref_hash(repo, ref_type, ref_name)
404 422 except (KeyError, ValueError):
405 423 raise JSONRPCError(
406 424 'The specified value:{type}:`{name}` does not exist, or is not allowed.'.format(
407 425 type=ref_type, name=ref_name))
408 426
409 427 return ':'.join([ref_type, ref_name, ref_hash])
410 428
411 429
412 430 def _get_commit_dict(
413 431 filename, op, new_revision=None, old_revision=None,
414 432 raw_diff=None, stats=None):
415 433 if stats is None:
416 434 stats = {
417 435 "added": None,
418 436 "binary": None,
419 437 "deleted": None
420 438 }
421 439 return {
422 440 "filename": safe_unicode(filename),
423 441 "op": op,
424 442
425 443 # extra details
426 444 "new_revision": new_revision,
427 445 "old_revision": old_revision,
428 446
429 447 "raw_diff": raw_diff,
430 448 "stats": stats
431 449 }
432
433
434 def _get_ref_hash(repo, type_, name):
435 vcs_repo = repo.scm_instance()
436 if type_ == 'branch' and vcs_repo.alias in ('hg', 'git'):
437 return vcs_repo.branches[name]
438 elif type_ == 'bookmark' and vcs_repo.alias == 'hg':
439 return vcs_repo.bookmarks[name]
440 else:
441 raise ValueError()
@@ -1,1069 +1,1069 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
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 20
21 21 import os
22 22
23 23 import mock
24 24 import pytest
25 25
26 26 from rhodecode.apps.repository.views.repo_files import RepoFilesView
27 27 from rhodecode.lib import helpers as h
28 28 from rhodecode.lib.compat import OrderedDict
29 29 from rhodecode.lib.ext_json import json
30 30 from rhodecode.lib.vcs import nodes
31 31
32 32 from rhodecode.lib.vcs.conf import settings
33 33 from rhodecode.tests import assert_session_flash
34 34 from rhodecode.tests.fixture import Fixture
35 35 from rhodecode.model.db import Session
36 36
37 37 fixture = Fixture()
38 38
39 39
40 40 def get_node_history(backend_type):
41 41 return {
42 42 'hg': json.loads(fixture.load_resource('hg_node_history_response.json')),
43 43 'git': json.loads(fixture.load_resource('git_node_history_response.json')),
44 44 'svn': json.loads(fixture.load_resource('svn_node_history_response.json')),
45 45 }[backend_type]
46 46
47 47
48 48 def route_path(name, params=None, **kwargs):
49 49 import urllib
50 50
51 51 base_url = {
52 52 'repo_summary': '/{repo_name}',
53 53 'repo_archivefile': '/{repo_name}/archive/{fname}',
54 54 'repo_files_diff': '/{repo_name}/diff/{f_path}',
55 55 'repo_files_diff_2way_redirect': '/{repo_name}/diff-2way/{f_path}',
56 56 'repo_files': '/{repo_name}/files/{commit_id}/{f_path}',
57 57 'repo_files:default_path': '/{repo_name}/files/{commit_id}/',
58 58 'repo_files:default_commit': '/{repo_name}/files',
59 59 'repo_files:rendered': '/{repo_name}/render/{commit_id}/{f_path}',
60 60 'repo_files:annotated': '/{repo_name}/annotate/{commit_id}/{f_path}',
61 61 'repo_files:annotated_previous': '/{repo_name}/annotate-previous/{commit_id}/{f_path}',
62 62 'repo_files_nodelist': '/{repo_name}/nodelist/{commit_id}/{f_path}',
63 63 'repo_file_raw': '/{repo_name}/raw/{commit_id}/{f_path}',
64 64 'repo_file_download': '/{repo_name}/download/{commit_id}/{f_path}',
65 65 'repo_file_history': '/{repo_name}/history/{commit_id}/{f_path}',
66 66 'repo_file_authors': '/{repo_name}/authors/{commit_id}/{f_path}',
67 67 'repo_files_remove_file': '/{repo_name}/remove_file/{commit_id}/{f_path}',
68 68 'repo_files_delete_file': '/{repo_name}/delete_file/{commit_id}/{f_path}',
69 69 'repo_files_edit_file': '/{repo_name}/edit_file/{commit_id}/{f_path}',
70 70 'repo_files_update_file': '/{repo_name}/update_file/{commit_id}/{f_path}',
71 71 'repo_files_add_file': '/{repo_name}/add_file/{commit_id}/{f_path}',
72 72 'repo_files_create_file': '/{repo_name}/create_file/{commit_id}/{f_path}',
73 73 'repo_nodetree_full': '/{repo_name}/nodetree_full/{commit_id}/{f_path}',
74 74 'repo_nodetree_full:default_path': '/{repo_name}/nodetree_full/{commit_id}/',
75 75 }[name].format(**kwargs)
76 76
77 77 if params:
78 78 base_url = '{}?{}'.format(base_url, urllib.urlencode(params))
79 79 return base_url
80 80
81 81
82 82 def assert_files_in_response(response, files, params):
83 83 template = (
84 84 'href="/%(repo_name)s/files/%(commit_id)s/%(name)s"')
85 85 _assert_items_in_response(response, files, template, params)
86 86
87 87
88 88 def assert_dirs_in_response(response, dirs, params):
89 89 template = (
90 90 'href="/%(repo_name)s/files/%(commit_id)s/%(name)s"')
91 91 _assert_items_in_response(response, dirs, template, params)
92 92
93 93
94 94 def _assert_items_in_response(response, items, template, params):
95 95 for item in items:
96 96 item_params = {'name': item}
97 97 item_params.update(params)
98 98 response.mustcontain(template % item_params)
99 99
100 100
101 101 def assert_timeago_in_response(response, items, params):
102 102 for item in items:
103 103 response.mustcontain(h.age_component(params['date']))
104 104
105 105
106 106 @pytest.mark.usefixtures("app")
107 107 class TestFilesViews(object):
108 108
109 109 def test_show_files(self, backend):
110 110 response = self.app.get(
111 111 route_path('repo_files',
112 112 repo_name=backend.repo_name,
113 113 commit_id='tip', f_path='/'))
114 114 commit = backend.repo.get_commit()
115 115
116 116 params = {
117 117 'repo_name': backend.repo_name,
118 118 'commit_id': commit.raw_id,
119 119 'date': commit.date
120 120 }
121 121 assert_dirs_in_response(response, ['docs', 'vcs'], params)
122 122 files = [
123 123 '.gitignore',
124 124 '.hgignore',
125 125 '.hgtags',
126 126 # TODO: missing in Git
127 127 # '.travis.yml',
128 128 'MANIFEST.in',
129 129 'README.rst',
130 130 # TODO: File is missing in svn repository
131 131 # 'run_test_and_report.sh',
132 132 'setup.cfg',
133 133 'setup.py',
134 134 'test_and_report.sh',
135 135 'tox.ini',
136 136 ]
137 137 assert_files_in_response(response, files, params)
138 138 assert_timeago_in_response(response, files, params)
139 139
140 140 def test_show_files_links_submodules_with_absolute_url(self, backend_hg):
141 141 repo = backend_hg['subrepos']
142 142 response = self.app.get(
143 143 route_path('repo_files',
144 144 repo_name=repo.repo_name,
145 145 commit_id='tip', f_path='/'))
146 146 assert_response = response.assert_response()
147 147 assert_response.contains_one_link(
148 148 'absolute-path @ 000000000000', 'http://example.com/absolute-path')
149 149
150 150 def test_show_files_links_submodules_with_absolute_url_subpaths(
151 151 self, backend_hg):
152 152 repo = backend_hg['subrepos']
153 153 response = self.app.get(
154 154 route_path('repo_files',
155 155 repo_name=repo.repo_name,
156 156 commit_id='tip', f_path='/'))
157 157 assert_response = response.assert_response()
158 158 assert_response.contains_one_link(
159 159 'subpaths-path @ 000000000000',
160 160 'http://sub-base.example.com/subpaths-path')
161 161
162 162 @pytest.mark.xfail_backends("svn", reason="Depends on branch support")
163 163 def test_files_menu(self, backend):
164 164 new_branch = "temp_branch_name"
165 165 commits = [
166 166 {'message': 'a'},
167 167 {'message': 'b', 'branch': new_branch}
168 168 ]
169 169 backend.create_repo(commits)
170 170 backend.repo.landing_rev = "branch:%s" % new_branch
171 171 Session().commit()
172 172
173 173 # get response based on tip and not new commit
174 174 response = self.app.get(
175 175 route_path('repo_files',
176 176 repo_name=backend.repo_name,
177 177 commit_id='tip', f_path='/'))
178 178
179 179 # make sure Files menu url is not tip but new commit
180 180 landing_rev = backend.repo.landing_rev[1]
181 181 files_url = route_path('repo_files:default_path',
182 182 repo_name=backend.repo_name,
183 183 commit_id=landing_rev)
184 184
185 185 assert landing_rev != 'tip'
186 186 response.mustcontain(
187 187 '<li class="active"><a class="menulink" href="%s">' % files_url)
188 188
189 189 def test_show_files_commit(self, backend):
190 190 commit = backend.repo.get_commit(commit_idx=32)
191 191
192 192 response = self.app.get(
193 193 route_path('repo_files',
194 194 repo_name=backend.repo_name,
195 195 commit_id=commit.raw_id, f_path='/'))
196 196
197 197 dirs = ['docs', 'tests']
198 198 files = ['README.rst']
199 199 params = {
200 200 'repo_name': backend.repo_name,
201 201 'commit_id': commit.raw_id,
202 202 }
203 203 assert_dirs_in_response(response, dirs, params)
204 204 assert_files_in_response(response, files, params)
205 205
206 206 def test_show_files_different_branch(self, backend):
207 207 branches = dict(
208 208 hg=(150, ['git']),
209 209 # TODO: Git test repository does not contain other branches
210 210 git=(633, ['master']),
211 211 # TODO: Branch support in Subversion
212 212 svn=(150, [])
213 213 )
214 214 idx, branches = branches[backend.alias]
215 215 commit = backend.repo.get_commit(commit_idx=idx)
216 216 response = self.app.get(
217 217 route_path('repo_files',
218 218 repo_name=backend.repo_name,
219 219 commit_id=commit.raw_id, f_path='/'))
220 220
221 221 assert_response = response.assert_response()
222 222 for branch in branches:
223 223 assert_response.element_contains('.tags .branchtag', branch)
224 224
225 225 def test_show_files_paging(self, backend):
226 226 repo = backend.repo
227 227 indexes = [73, 92, 109, 1, 0]
228 228 idx_map = [(rev, repo.get_commit(commit_idx=rev).raw_id)
229 229 for rev in indexes]
230 230
231 231 for idx in idx_map:
232 232 response = self.app.get(
233 233 route_path('repo_files',
234 234 repo_name=backend.repo_name,
235 235 commit_id=idx[1], f_path='/'))
236 236
237 237 response.mustcontain("""r%s:%s""" % (idx[0], idx[1][:8]))
238 238
239 239 def test_file_source(self, backend):
240 240 commit = backend.repo.get_commit(commit_idx=167)
241 241 response = self.app.get(
242 242 route_path('repo_files',
243 243 repo_name=backend.repo_name,
244 244 commit_id=commit.raw_id, f_path='vcs/nodes.py'))
245 245
246 246 msgbox = """<div class="commit right-content">%s</div>"""
247 247 response.mustcontain(msgbox % (commit.message, ))
248 248
249 249 assert_response = response.assert_response()
250 250 if commit.branch:
251 251 assert_response.element_contains(
252 252 '.tags.tags-main .branchtag', commit.branch)
253 253 if commit.tags:
254 254 for tag in commit.tags:
255 255 assert_response.element_contains('.tags.tags-main .tagtag', tag)
256 256
257 257 def test_file_source_annotated(self, backend):
258 258 response = self.app.get(
259 259 route_path('repo_files:annotated',
260 260 repo_name=backend.repo_name,
261 261 commit_id='tip', f_path='vcs/nodes.py'))
262 262 expected_commits = {
263 263 'hg': 'r356',
264 264 'git': 'r345',
265 265 'svn': 'r208',
266 266 }
267 267 response.mustcontain(expected_commits[backend.alias])
268 268
269 269 def test_file_source_authors(self, backend):
270 270 response = self.app.get(
271 271 route_path('repo_file_authors',
272 272 repo_name=backend.repo_name,
273 273 commit_id='tip', f_path='vcs/nodes.py'))
274 274 expected_authors = {
275 275 'hg': ('Marcin Kuzminski', 'Lukasz Balcerzak'),
276 276 'git': ('Marcin Kuzminski', 'Lukasz Balcerzak'),
277 277 'svn': ('marcin', 'lukasz'),
278 278 }
279 279
280 280 for author in expected_authors[backend.alias]:
281 281 response.mustcontain(author)
282 282
283 283 def test_file_source_authors_with_annotation(self, backend):
284 284 response = self.app.get(
285 285 route_path('repo_file_authors',
286 286 repo_name=backend.repo_name,
287 287 commit_id='tip', f_path='vcs/nodes.py',
288 288 params=dict(annotate=1)))
289 289 expected_authors = {
290 290 'hg': ('Marcin Kuzminski', 'Lukasz Balcerzak'),
291 291 'git': ('Marcin Kuzminski', 'Lukasz Balcerzak'),
292 292 'svn': ('marcin', 'lukasz'),
293 293 }
294 294
295 295 for author in expected_authors[backend.alias]:
296 296 response.mustcontain(author)
297 297
298 298 def test_file_source_history(self, backend, xhr_header):
299 299 response = self.app.get(
300 300 route_path('repo_file_history',
301 301 repo_name=backend.repo_name,
302 302 commit_id='tip', f_path='vcs/nodes.py'),
303 303 extra_environ=xhr_header)
304 304 assert get_node_history(backend.alias) == json.loads(response.body)
305 305
306 306 def test_file_source_history_svn(self, backend_svn, xhr_header):
307 307 simple_repo = backend_svn['svn-simple-layout']
308 308 response = self.app.get(
309 309 route_path('repo_file_history',
310 310 repo_name=simple_repo.repo_name,
311 311 commit_id='tip', f_path='trunk/example.py'),
312 312 extra_environ=xhr_header)
313 313
314 314 expected_data = json.loads(
315 315 fixture.load_resource('svn_node_history_branches.json'))
316 316 assert expected_data == response.json
317 317
318 318 def test_file_source_history_with_annotation(self, backend, xhr_header):
319 319 response = self.app.get(
320 320 route_path('repo_file_history',
321 321 repo_name=backend.repo_name,
322 322 commit_id='tip', f_path='vcs/nodes.py',
323 323 params=dict(annotate=1)),
324 324
325 325 extra_environ=xhr_header)
326 326 assert get_node_history(backend.alias) == json.loads(response.body)
327 327
328 328 def test_tree_search_top_level(self, backend, xhr_header):
329 329 commit = backend.repo.get_commit(commit_idx=173)
330 330 response = self.app.get(
331 331 route_path('repo_files_nodelist',
332 332 repo_name=backend.repo_name,
333 333 commit_id=commit.raw_id, f_path='/'),
334 334 extra_environ=xhr_header)
335 335 assert 'nodes' in response.json
336 336 assert {'name': 'docs', 'type': 'dir'} in response.json['nodes']
337 337
338 338 def test_tree_search_missing_xhr(self, backend):
339 339 self.app.get(
340 340 route_path('repo_files_nodelist',
341 341 repo_name=backend.repo_name,
342 342 commit_id='tip', f_path='/'),
343 343 status=404)
344 344
345 345 def test_tree_search_at_path(self, backend, xhr_header):
346 346 commit = backend.repo.get_commit(commit_idx=173)
347 347 response = self.app.get(
348 348 route_path('repo_files_nodelist',
349 349 repo_name=backend.repo_name,
350 350 commit_id=commit.raw_id, f_path='/docs'),
351 351 extra_environ=xhr_header)
352 352 assert 'nodes' in response.json
353 353 nodes = response.json['nodes']
354 354 assert {'name': 'docs/api', 'type': 'dir'} in nodes
355 355 assert {'name': 'docs/index.rst', 'type': 'file'} in nodes
356 356
357 357 def test_tree_search_at_path_2nd_level(self, backend, xhr_header):
358 358 commit = backend.repo.get_commit(commit_idx=173)
359 359 response = self.app.get(
360 360 route_path('repo_files_nodelist',
361 361 repo_name=backend.repo_name,
362 362 commit_id=commit.raw_id, f_path='/docs/api'),
363 363 extra_environ=xhr_header)
364 364 assert 'nodes' in response.json
365 365 nodes = response.json['nodes']
366 366 assert {'name': 'docs/api/index.rst', 'type': 'file'} in nodes
367 367
368 368 def test_tree_search_at_path_missing_xhr(self, backend):
369 369 self.app.get(
370 370 route_path('repo_files_nodelist',
371 371 repo_name=backend.repo_name,
372 372 commit_id='tip', f_path='/docs'),
373 373 status=404)
374 374
375 375 def test_nodetree(self, backend, xhr_header):
376 376 commit = backend.repo.get_commit(commit_idx=173)
377 377 response = self.app.get(
378 378 route_path('repo_nodetree_full',
379 379 repo_name=backend.repo_name,
380 380 commit_id=commit.raw_id, f_path='/'),
381 381 extra_environ=xhr_header)
382 382
383 383 assert_response = response.assert_response()
384 384
385 385 for attr in ['data-commit-id', 'data-date', 'data-author']:
386 386 elements = assert_response.get_elements('[{}]'.format(attr))
387 387 assert len(elements) > 1
388 388
389 389 for element in elements:
390 390 assert element.get(attr)
391 391
392 392 def test_nodetree_if_file(self, backend, xhr_header):
393 393 commit = backend.repo.get_commit(commit_idx=173)
394 394 response = self.app.get(
395 395 route_path('repo_nodetree_full',
396 396 repo_name=backend.repo_name,
397 397 commit_id=commit.raw_id, f_path='README.rst'),
398 398 extra_environ=xhr_header)
399 399 assert response.body == ''
400 400
401 401 def test_nodetree_wrong_path(self, backend, xhr_header):
402 402 commit = backend.repo.get_commit(commit_idx=173)
403 403 response = self.app.get(
404 404 route_path('repo_nodetree_full',
405 405 repo_name=backend.repo_name,
406 406 commit_id=commit.raw_id, f_path='/dont-exist'),
407 407 extra_environ=xhr_header)
408 408
409 409 err = 'error: There is no file nor ' \
410 410 'directory at the given path'
411 411 assert err in response.body
412 412
413 413 def test_nodetree_missing_xhr(self, backend):
414 414 self.app.get(
415 415 route_path('repo_nodetree_full',
416 416 repo_name=backend.repo_name,
417 417 commit_id='tip', f_path='/'),
418 418 status=404)
419 419
420 420
421 421 @pytest.mark.usefixtures("app", "autologin_user")
422 422 class TestRawFileHandling(object):
423 423
424 424 def test_download_file(self, backend):
425 425 commit = backend.repo.get_commit(commit_idx=173)
426 426 response = self.app.get(
427 427 route_path('repo_file_download',
428 428 repo_name=backend.repo_name,
429 429 commit_id=commit.raw_id, f_path='vcs/nodes.py'),)
430 430
431 assert response.content_disposition == "attachment; filename=nodes.py"
431 assert response.content_disposition == 'attachment; filename="nodes.py"; filename*=UTF-8\'\'nodes.py'
432 432 assert response.content_type == "text/x-python"
433 433
434 434 def test_download_file_wrong_cs(self, backend):
435 435 raw_id = u'ERRORce30c96924232dffcd24178a07ffeb5dfc'
436 436
437 437 response = self.app.get(
438 438 route_path('repo_file_download',
439 439 repo_name=backend.repo_name,
440 440 commit_id=raw_id, f_path='vcs/nodes.svg'),
441 441 status=404)
442 442
443 443 msg = """No such commit exists for this repository"""
444 444 response.mustcontain(msg)
445 445
446 446 def test_download_file_wrong_f_path(self, backend):
447 447 commit = backend.repo.get_commit(commit_idx=173)
448 448 f_path = 'vcs/ERRORnodes.py'
449 449
450 450 response = self.app.get(
451 451 route_path('repo_file_download',
452 452 repo_name=backend.repo_name,
453 453 commit_id=commit.raw_id, f_path=f_path),
454 454 status=404)
455 455
456 456 msg = (
457 457 "There is no file nor directory at the given path: "
458 458 "`%s` at commit %s" % (f_path, commit.short_id))
459 459 response.mustcontain(msg)
460 460
461 461 def test_file_raw(self, backend):
462 462 commit = backend.repo.get_commit(commit_idx=173)
463 463 response = self.app.get(
464 464 route_path('repo_file_raw',
465 465 repo_name=backend.repo_name,
466 466 commit_id=commit.raw_id, f_path='vcs/nodes.py'),)
467 467
468 468 assert response.content_type == "text/plain"
469 469
470 470 def test_file_raw_binary(self, backend):
471 471 commit = backend.repo.get_commit()
472 472 response = self.app.get(
473 473 route_path('repo_file_raw',
474 474 repo_name=backend.repo_name,
475 475 commit_id=commit.raw_id,
476 476 f_path='docs/theme/ADC/static/breadcrumb_background.png'),)
477 477
478 478 assert response.content_disposition == 'inline'
479 479
480 480 def test_raw_file_wrong_cs(self, backend):
481 481 raw_id = u'ERRORcce30c96924232dffcd24178a07ffeb5dfc'
482 482
483 483 response = self.app.get(
484 484 route_path('repo_file_raw',
485 485 repo_name=backend.repo_name,
486 486 commit_id=raw_id, f_path='vcs/nodes.svg'),
487 487 status=404)
488 488
489 489 msg = """No such commit exists for this repository"""
490 490 response.mustcontain(msg)
491 491
492 492 def test_raw_wrong_f_path(self, backend):
493 493 commit = backend.repo.get_commit(commit_idx=173)
494 494 f_path = 'vcs/ERRORnodes.py'
495 495 response = self.app.get(
496 496 route_path('repo_file_raw',
497 497 repo_name=backend.repo_name,
498 498 commit_id=commit.raw_id, f_path=f_path),
499 499 status=404)
500 500
501 501 msg = (
502 502 "There is no file nor directory at the given path: "
503 503 "`%s` at commit %s" % (f_path, commit.short_id))
504 504 response.mustcontain(msg)
505 505
506 506 def test_raw_svg_should_not_be_rendered(self, backend):
507 507 backend.create_repo()
508 508 backend.ensure_file("xss.svg")
509 509 response = self.app.get(
510 510 route_path('repo_file_raw',
511 511 repo_name=backend.repo_name,
512 512 commit_id='tip', f_path='xss.svg'),)
513 513 # If the content type is image/svg+xml then it allows to render HTML
514 514 # and malicious SVG.
515 515 assert response.content_type == "text/plain"
516 516
517 517
518 518 @pytest.mark.usefixtures("app")
519 519 class TestRepositoryArchival(object):
520 520
521 521 def test_archival(self, backend):
522 522 backend.enable_downloads()
523 523 commit = backend.repo.get_commit(commit_idx=173)
524 524 for archive, info in settings.ARCHIVE_SPECS.items():
525 525 mime_type, arch_ext = info
526 526 short = commit.short_id + arch_ext
527 527 fname = commit.raw_id + arch_ext
528 528 filename = '%s-%s' % (backend.repo_name, short)
529 529 response = self.app.get(
530 530 route_path('repo_archivefile',
531 531 repo_name=backend.repo_name,
532 532 fname=fname))
533 533
534 534 assert response.status == '200 OK'
535 535 headers = [
536 536 ('Content-Disposition', 'attachment; filename=%s' % filename),
537 537 ('Content-Type', '%s' % mime_type),
538 538 ]
539 539
540 540 for header in headers:
541 541 assert header in response.headers.items()
542 542
543 543 @pytest.mark.parametrize('arch_ext',[
544 544 'tar', 'rar', 'x', '..ax', '.zipz', 'tar.gz.tar'])
545 545 def test_archival_wrong_ext(self, backend, arch_ext):
546 546 backend.enable_downloads()
547 547 commit = backend.repo.get_commit(commit_idx=173)
548 548
549 549 fname = commit.raw_id + '.' + arch_ext
550 550
551 551 response = self.app.get(
552 552 route_path('repo_archivefile',
553 553 repo_name=backend.repo_name,
554 554 fname=fname))
555 555 response.mustcontain(
556 556 'Unknown archive type for: `{}`'.format(fname))
557 557
558 558 @pytest.mark.parametrize('commit_id', [
559 559 '00x000000', 'tar', 'wrong', '@$@$42413232', '232dffcd'])
560 560 def test_archival_wrong_commit_id(self, backend, commit_id):
561 561 backend.enable_downloads()
562 562 fname = '%s.zip' % commit_id
563 563
564 564 response = self.app.get(
565 565 route_path('repo_archivefile',
566 566 repo_name=backend.repo_name,
567 567 fname=fname))
568 568 response.mustcontain('Unknown commit_id')
569 569
570 570
571 571 @pytest.mark.usefixtures("app")
572 572 class TestFilesDiff(object):
573 573
574 574 @pytest.mark.parametrize("diff", ['diff', 'download', 'raw'])
575 575 def test_file_full_diff(self, backend, diff):
576 576 commit1 = backend.repo.get_commit(commit_idx=-1)
577 577 commit2 = backend.repo.get_commit(commit_idx=-2)
578 578
579 579 response = self.app.get(
580 580 route_path('repo_files_diff',
581 581 repo_name=backend.repo_name,
582 582 f_path='README'),
583 583 params={
584 584 'diff1': commit2.raw_id,
585 585 'diff2': commit1.raw_id,
586 586 'fulldiff': '1',
587 587 'diff': diff,
588 588 })
589 589
590 590 if diff == 'diff':
591 591 # use redirect since this is OLD view redirecting to compare page
592 592 response = response.follow()
593 593
594 594 # It's a symlink to README.rst
595 595 response.mustcontain('README.rst')
596 596 response.mustcontain('No newline at end of file')
597 597
598 598 def test_file_binary_diff(self, backend):
599 599 commits = [
600 600 {'message': 'First commit'},
601 601 {'message': 'Commit with binary',
602 602 'added': [nodes.FileNode('file.bin', content='\0BINARY\0')]},
603 603 ]
604 604 repo = backend.create_repo(commits=commits)
605 605
606 606 response = self.app.get(
607 607 route_path('repo_files_diff',
608 608 repo_name=backend.repo_name,
609 609 f_path='file.bin'),
610 610 params={
611 611 'diff1': repo.get_commit(commit_idx=0).raw_id,
612 612 'diff2': repo.get_commit(commit_idx=1).raw_id,
613 613 'fulldiff': '1',
614 614 'diff': 'diff',
615 615 })
616 616 # use redirect since this is OLD view redirecting to compare page
617 617 response = response.follow()
618 618 response.mustcontain('Expand 1 commit')
619 619 response.mustcontain('1 file changed: 0 inserted, 0 deleted')
620 620
621 621 if backend.alias == 'svn':
622 622 response.mustcontain('new file 10644')
623 623 # TODO(marcink): SVN doesn't yet detect binary changes
624 624 else:
625 625 response.mustcontain('new file 100644')
626 626 response.mustcontain('binary diff hidden')
627 627
628 628 def test_diff_2way(self, backend):
629 629 commit1 = backend.repo.get_commit(commit_idx=-1)
630 630 commit2 = backend.repo.get_commit(commit_idx=-2)
631 631 response = self.app.get(
632 632 route_path('repo_files_diff_2way_redirect',
633 633 repo_name=backend.repo_name,
634 634 f_path='README'),
635 635 params={
636 636 'diff1': commit2.raw_id,
637 637 'diff2': commit1.raw_id,
638 638 })
639 639 # use redirect since this is OLD view redirecting to compare page
640 640 response = response.follow()
641 641
642 642 # It's a symlink to README.rst
643 643 response.mustcontain('README.rst')
644 644 response.mustcontain('No newline at end of file')
645 645
646 646 def test_requires_one_commit_id(self, backend, autologin_user):
647 647 response = self.app.get(
648 648 route_path('repo_files_diff',
649 649 repo_name=backend.repo_name,
650 650 f_path='README.rst'),
651 651 status=400)
652 652 response.mustcontain(
653 653 'Need query parameter', 'diff1', 'diff2', 'to generate a diff.')
654 654
655 655 def test_returns_no_files_if_file_does_not_exist(self, vcsbackend):
656 656 repo = vcsbackend.repo
657 657 response = self.app.get(
658 658 route_path('repo_files_diff',
659 659 repo_name=repo.name,
660 660 f_path='does-not-exist-in-any-commit'),
661 661 params={
662 662 'diff1': repo[0].raw_id,
663 663 'diff2': repo[1].raw_id
664 664 })
665 665
666 666 response = response.follow()
667 667 response.mustcontain('No files')
668 668
669 669 def test_returns_redirect_if_file_not_changed(self, backend):
670 670 commit = backend.repo.get_commit(commit_idx=-1)
671 671 response = self.app.get(
672 672 route_path('repo_files_diff_2way_redirect',
673 673 repo_name=backend.repo_name,
674 674 f_path='README'),
675 675 params={
676 676 'diff1': commit.raw_id,
677 677 'diff2': commit.raw_id,
678 678 })
679 679
680 680 response = response.follow()
681 681 response.mustcontain('No files')
682 682 response.mustcontain('No commits in this compare')
683 683
684 684 def test_supports_diff_to_different_path_svn(self, backend_svn):
685 685 #TODO: check this case
686 686 return
687 687
688 688 repo = backend_svn['svn-simple-layout'].scm_instance()
689 689 commit_id_1 = '24'
690 690 commit_id_2 = '26'
691 691
692 692 response = self.app.get(
693 693 route_path('repo_files_diff',
694 694 repo_name=backend_svn.repo_name,
695 695 f_path='trunk/example.py'),
696 696 params={
697 697 'diff1': 'tags/v0.2/example.py@' + commit_id_1,
698 698 'diff2': commit_id_2,
699 699 })
700 700
701 701 response = response.follow()
702 702 response.mustcontain(
703 703 # diff contains this
704 704 "Will print out a useful message on invocation.")
705 705
706 706 # Note: Expecting that we indicate the user what's being compared
707 707 response.mustcontain("trunk/example.py")
708 708 response.mustcontain("tags/v0.2/example.py")
709 709
710 710 def test_show_rev_redirects_to_svn_path(self, backend_svn):
711 711 #TODO: check this case
712 712 return
713 713
714 714 repo = backend_svn['svn-simple-layout'].scm_instance()
715 715 commit_id = repo[-1].raw_id
716 716
717 717 response = self.app.get(
718 718 route_path('repo_files_diff',
719 719 repo_name=backend_svn.repo_name,
720 720 f_path='trunk/example.py'),
721 721 params={
722 722 'diff1': 'branches/argparse/example.py@' + commit_id,
723 723 'diff2': commit_id,
724 724 },
725 725 status=302)
726 726 response = response.follow()
727 727 assert response.headers['Location'].endswith(
728 728 'svn-svn-simple-layout/files/26/branches/argparse/example.py')
729 729
730 730 def test_show_rev_and_annotate_redirects_to_svn_path(self, backend_svn):
731 731 #TODO: check this case
732 732 return
733 733
734 734 repo = backend_svn['svn-simple-layout'].scm_instance()
735 735 commit_id = repo[-1].raw_id
736 736 response = self.app.get(
737 737 route_path('repo_files_diff',
738 738 repo_name=backend_svn.repo_name,
739 739 f_path='trunk/example.py'),
740 740 params={
741 741 'diff1': 'branches/argparse/example.py@' + commit_id,
742 742 'diff2': commit_id,
743 743 'show_rev': 'Show at Revision',
744 744 'annotate': 'true',
745 745 },
746 746 status=302)
747 747 response = response.follow()
748 748 assert response.headers['Location'].endswith(
749 749 'svn-svn-simple-layout/annotate/26/branches/argparse/example.py')
750 750
751 751
752 752 @pytest.mark.usefixtures("app", "autologin_user")
753 753 class TestModifyFilesWithWebInterface(object):
754 754
755 755 def test_add_file_view(self, backend):
756 756 self.app.get(
757 757 route_path('repo_files_add_file',
758 758 repo_name=backend.repo_name,
759 759 commit_id='tip', f_path='/')
760 760 )
761 761
762 762 @pytest.mark.xfail_backends("svn", reason="Depends on online editing")
763 763 def test_add_file_into_repo_missing_content(self, backend, csrf_token):
764 764 repo = backend.create_repo()
765 765 filename = 'init.py'
766 766 response = self.app.post(
767 767 route_path('repo_files_create_file',
768 768 repo_name=backend.repo_name,
769 769 commit_id='tip', f_path='/'),
770 770 params={
771 771 'content': "",
772 772 'filename': filename,
773 773 'location': "",
774 774 'csrf_token': csrf_token,
775 775 },
776 776 status=302)
777 777 assert_session_flash(response,
778 778 'Successfully committed new file `{}`'.format(
779 779 os.path.join(filename)))
780 780
781 781 def test_add_file_into_repo_missing_filename(self, backend, csrf_token):
782 782 response = self.app.post(
783 783 route_path('repo_files_create_file',
784 784 repo_name=backend.repo_name,
785 785 commit_id='tip', f_path='/'),
786 786 params={
787 787 'content': "foo",
788 788 'csrf_token': csrf_token,
789 789 },
790 790 status=302)
791 791
792 792 assert_session_flash(response, 'No filename')
793 793
794 794 def test_add_file_into_repo_errors_and_no_commits(
795 795 self, backend, csrf_token):
796 796 repo = backend.create_repo()
797 797 # Create a file with no filename, it will display an error but
798 798 # the repo has no commits yet
799 799 response = self.app.post(
800 800 route_path('repo_files_create_file',
801 801 repo_name=repo.repo_name,
802 802 commit_id='tip', f_path='/'),
803 803 params={
804 804 'content': "foo",
805 805 'csrf_token': csrf_token,
806 806 },
807 807 status=302)
808 808
809 809 assert_session_flash(response, 'No filename')
810 810
811 811 # Not allowed, redirect to the summary
812 812 redirected = response.follow()
813 813 summary_url = h.route_path('repo_summary', repo_name=repo.repo_name)
814 814
815 815 # As there are no commits, displays the summary page with the error of
816 816 # creating a file with no filename
817 817
818 818 assert redirected.request.path == summary_url
819 819
820 820 @pytest.mark.parametrize("location, filename", [
821 821 ('/abs', 'foo'),
822 822 ('../rel', 'foo'),
823 823 ('file/../foo', 'foo'),
824 824 ])
825 825 def test_add_file_into_repo_bad_filenames(
826 826 self, location, filename, backend, csrf_token):
827 827 response = self.app.post(
828 828 route_path('repo_files_create_file',
829 829 repo_name=backend.repo_name,
830 830 commit_id='tip', f_path='/'),
831 831 params={
832 832 'content': "foo",
833 833 'filename': filename,
834 834 'location': location,
835 835 'csrf_token': csrf_token,
836 836 },
837 837 status=302)
838 838
839 839 assert_session_flash(
840 840 response,
841 841 'The location specified must be a relative path and must not '
842 842 'contain .. in the path')
843 843
844 844 @pytest.mark.parametrize("cnt, location, filename", [
845 845 (1, '', 'foo.txt'),
846 846 (2, 'dir', 'foo.rst'),
847 847 (3, 'rel/dir', 'foo.bar'),
848 848 ])
849 849 def test_add_file_into_repo(self, cnt, location, filename, backend,
850 850 csrf_token):
851 851 repo = backend.create_repo()
852 852 response = self.app.post(
853 853 route_path('repo_files_create_file',
854 854 repo_name=repo.repo_name,
855 855 commit_id='tip', f_path='/'),
856 856 params={
857 857 'content': "foo",
858 858 'filename': filename,
859 859 'location': location,
860 860 'csrf_token': csrf_token,
861 861 },
862 862 status=302)
863 863 assert_session_flash(response,
864 864 'Successfully committed new file `{}`'.format(
865 865 os.path.join(location, filename)))
866 866
867 867 def test_edit_file_view(self, backend):
868 868 response = self.app.get(
869 869 route_path('repo_files_edit_file',
870 870 repo_name=backend.repo_name,
871 871 commit_id=backend.default_head_id,
872 872 f_path='vcs/nodes.py'),
873 873 status=200)
874 874 response.mustcontain("Module holding everything related to vcs nodes.")
875 875
876 876 def test_edit_file_view_not_on_branch(self, backend):
877 877 repo = backend.create_repo()
878 878 backend.ensure_file("vcs/nodes.py")
879 879
880 880 response = self.app.get(
881 881 route_path('repo_files_edit_file',
882 882 repo_name=repo.repo_name,
883 883 commit_id='tip',
884 884 f_path='vcs/nodes.py'),
885 885 status=302)
886 886 assert_session_flash(
887 887 response,
888 888 'You can only edit files with commit being a valid branch')
889 889
890 890 def test_edit_file_view_commit_changes(self, backend, csrf_token):
891 891 repo = backend.create_repo()
892 892 backend.ensure_file("vcs/nodes.py", content="print 'hello'")
893 893
894 894 response = self.app.post(
895 895 route_path('repo_files_update_file',
896 896 repo_name=repo.repo_name,
897 897 commit_id=backend.default_head_id,
898 898 f_path='vcs/nodes.py'),
899 899 params={
900 900 'content': "print 'hello world'",
901 901 'message': 'I committed',
902 902 'filename': "vcs/nodes.py",
903 903 'csrf_token': csrf_token,
904 904 },
905 905 status=302)
906 906 assert_session_flash(
907 907 response, 'Successfully committed changes to file `vcs/nodes.py`')
908 908 tip = repo.get_commit(commit_idx=-1)
909 909 assert tip.message == 'I committed'
910 910
911 911 def test_edit_file_view_commit_changes_default_message(self, backend,
912 912 csrf_token):
913 913 repo = backend.create_repo()
914 914 backend.ensure_file("vcs/nodes.py", content="print 'hello'")
915 915
916 916 commit_id = (
917 917 backend.default_branch_name or
918 918 backend.repo.scm_instance().commit_ids[-1])
919 919
920 920 response = self.app.post(
921 921 route_path('repo_files_update_file',
922 922 repo_name=repo.repo_name,
923 923 commit_id=commit_id,
924 924 f_path='vcs/nodes.py'),
925 925 params={
926 926 'content': "print 'hello world'",
927 927 'message': '',
928 928 'filename': "vcs/nodes.py",
929 929 'csrf_token': csrf_token,
930 930 },
931 931 status=302)
932 932 assert_session_flash(
933 933 response, 'Successfully committed changes to file `vcs/nodes.py`')
934 934 tip = repo.get_commit(commit_idx=-1)
935 935 assert tip.message == 'Edited file vcs/nodes.py via RhodeCode Enterprise'
936 936
937 937 def test_delete_file_view(self, backend):
938 938 self.app.get(
939 939 route_path('repo_files_remove_file',
940 940 repo_name=backend.repo_name,
941 941 commit_id=backend.default_head_id,
942 942 f_path='vcs/nodes.py'),
943 943 status=200)
944 944
945 945 def test_delete_file_view_not_on_branch(self, backend):
946 946 repo = backend.create_repo()
947 947 backend.ensure_file('vcs/nodes.py')
948 948
949 949 response = self.app.get(
950 950 route_path('repo_files_remove_file',
951 951 repo_name=repo.repo_name,
952 952 commit_id='tip',
953 953 f_path='vcs/nodes.py'),
954 954 status=302)
955 955 assert_session_flash(
956 956 response,
957 957 'You can only delete files with commit being a valid branch')
958 958
959 959 def test_delete_file_view_commit_changes(self, backend, csrf_token):
960 960 repo = backend.create_repo()
961 961 backend.ensure_file("vcs/nodes.py")
962 962
963 963 response = self.app.post(
964 964 route_path('repo_files_delete_file',
965 965 repo_name=repo.repo_name,
966 966 commit_id=backend.default_head_id,
967 967 f_path='vcs/nodes.py'),
968 968 params={
969 969 'message': 'i commited',
970 970 'csrf_token': csrf_token,
971 971 },
972 972 status=302)
973 973 assert_session_flash(
974 974 response, 'Successfully deleted file `vcs/nodes.py`')
975 975
976 976
977 977 @pytest.mark.usefixtures("app")
978 978 class TestFilesViewOtherCases(object):
979 979
980 980 def test_access_empty_repo_redirect_to_summary_with_alert_write_perms(
981 981 self, backend_stub, autologin_regular_user, user_regular,
982 982 user_util):
983 983
984 984 repo = backend_stub.create_repo()
985 985 user_util.grant_user_permission_to_repo(
986 986 repo, user_regular, 'repository.write')
987 987 response = self.app.get(
988 988 route_path('repo_files',
989 989 repo_name=repo.repo_name,
990 990 commit_id='tip', f_path='/'))
991 991
992 992 repo_file_add_url = route_path(
993 993 'repo_files_add_file',
994 994 repo_name=repo.repo_name,
995 995 commit_id=0, f_path='') + '#edit'
996 996
997 997 assert_session_flash(
998 998 response,
999 999 'There are no files yet. <a class="alert-link" '
1000 1000 'href="{}">Click here to add a new file.</a>'
1001 1001 .format(repo_file_add_url))
1002 1002
1003 1003 def test_access_empty_repo_redirect_to_summary_with_alert_no_write_perms(
1004 1004 self, backend_stub, autologin_regular_user):
1005 1005 repo = backend_stub.create_repo()
1006 1006 # init session for anon user
1007 1007 route_path('repo_summary', repo_name=repo.repo_name)
1008 1008
1009 1009 repo_file_add_url = route_path(
1010 1010 'repo_files_add_file',
1011 1011 repo_name=repo.repo_name,
1012 1012 commit_id=0, f_path='') + '#edit'
1013 1013
1014 1014 response = self.app.get(
1015 1015 route_path('repo_files',
1016 1016 repo_name=repo.repo_name,
1017 1017 commit_id='tip', f_path='/'))
1018 1018
1019 1019 assert_session_flash(response, no_=repo_file_add_url)
1020 1020
1021 1021 @pytest.mark.parametrize('file_node', [
1022 1022 'archive/file.zip',
1023 1023 'diff/my-file.txt',
1024 1024 'render.py',
1025 1025 'render',
1026 1026 'remove_file',
1027 1027 'remove_file/to-delete.txt',
1028 1028 ])
1029 1029 def test_file_names_equal_to_routes_parts(self, backend, file_node):
1030 1030 backend.create_repo()
1031 1031 backend.ensure_file(file_node)
1032 1032
1033 1033 self.app.get(
1034 1034 route_path('repo_files',
1035 1035 repo_name=backend.repo_name,
1036 1036 commit_id='tip', f_path=file_node),
1037 1037 status=200)
1038 1038
1039 1039
1040 1040 class TestAdjustFilePathForSvn(object):
1041 1041 """
1042 1042 SVN specific adjustments of node history in RepoFilesView.
1043 1043 """
1044 1044
1045 1045 def test_returns_path_relative_to_matched_reference(self):
1046 1046 repo = self._repo(branches=['trunk'])
1047 1047 self.assert_file_adjustment('trunk/file', 'file', repo)
1048 1048
1049 1049 def test_does_not_modify_file_if_no_reference_matches(self):
1050 1050 repo = self._repo(branches=['trunk'])
1051 1051 self.assert_file_adjustment('notes/file', 'notes/file', repo)
1052 1052
1053 1053 def test_does_not_adjust_partial_directory_names(self):
1054 1054 repo = self._repo(branches=['trun'])
1055 1055 self.assert_file_adjustment('trunk/file', 'trunk/file', repo)
1056 1056
1057 1057 def test_is_robust_to_patterns_which_prefix_other_patterns(self):
1058 1058 repo = self._repo(branches=['trunk', 'trunk/new', 'trunk/old'])
1059 1059 self.assert_file_adjustment('trunk/new/file', 'file', repo)
1060 1060
1061 1061 def assert_file_adjustment(self, f_path, expected, repo):
1062 1062 result = RepoFilesView.adjust_file_path_for_svn(f_path, repo)
1063 1063 assert result == expected
1064 1064
1065 1065 def _repo(self, branches=None):
1066 1066 repo = mock.Mock()
1067 1067 repo.branches = OrderedDict((name, '0') for name in branches or [])
1068 1068 repo.tags = {}
1069 1069 return repo
@@ -1,1386 +1,1392 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
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 20
21 21 import itertools
22 22 import logging
23 23 import os
24 24 import shutil
25 25 import tempfile
26 26 import collections
27 import urllib
27 28
28 29 from pyramid.httpexceptions import HTTPNotFound, HTTPBadRequest, HTTPFound
29 30 from pyramid.view import view_config
30 31 from pyramid.renderers import render
31 32 from pyramid.response import Response
32 33
33 34 import rhodecode
34 35 from rhodecode.apps._base import RepoAppView
35 36
36 37
37 38 from rhodecode.lib import diffs, helpers as h, rc_cache
38 39 from rhodecode.lib import audit_logger
39 40 from rhodecode.lib.view_utils import parse_path_ref
40 41 from rhodecode.lib.exceptions import NonRelativePathError
41 42 from rhodecode.lib.codeblocks import (
42 43 filenode_as_lines_tokens, filenode_as_annotated_lines_tokens)
43 44 from rhodecode.lib.utils2 import (
44 45 convert_line_endings, detect_mode, safe_str, str2bool, safe_int)
45 46 from rhodecode.lib.auth import (
46 47 LoginRequired, HasRepoPermissionAnyDecorator, CSRFRequired)
47 48 from rhodecode.lib.vcs import path as vcspath
48 49 from rhodecode.lib.vcs.backends.base import EmptyCommit
49 50 from rhodecode.lib.vcs.conf import settings
50 51 from rhodecode.lib.vcs.nodes import FileNode
51 52 from rhodecode.lib.vcs.exceptions import (
52 53 RepositoryError, CommitDoesNotExistError, EmptyRepositoryError,
53 54 ImproperArchiveTypeError, VCSError, NodeAlreadyExistsError,
54 55 NodeDoesNotExistError, CommitError, NodeError)
55 56
56 57 from rhodecode.model.scm import ScmModel
57 58 from rhodecode.model.db import Repository
58 59
59 60 log = logging.getLogger(__name__)
60 61
61 62
62 63 class RepoFilesView(RepoAppView):
63 64
64 65 @staticmethod
65 66 def adjust_file_path_for_svn(f_path, repo):
66 67 """
67 68 Computes the relative path of `f_path`.
68 69
69 70 This is mainly based on prefix matching of the recognized tags and
70 71 branches in the underlying repository.
71 72 """
72 73 tags_and_branches = itertools.chain(
73 74 repo.branches.iterkeys(),
74 75 repo.tags.iterkeys())
75 76 tags_and_branches = sorted(tags_and_branches, key=len, reverse=True)
76 77
77 78 for name in tags_and_branches:
78 79 if f_path.startswith('{}/'.format(name)):
79 80 f_path = vcspath.relpath(f_path, name)
80 81 break
81 82 return f_path
82 83
83 84 def load_default_context(self):
84 85 c = self._get_local_tmpl_context(include_app_defaults=True)
85 86 c.rhodecode_repo = self.rhodecode_vcs_repo
86 87 return c
87 88
88 89 def _ensure_not_locked(self):
89 90 _ = self.request.translate
90 91
91 92 repo = self.db_repo
92 93 if repo.enable_locking and repo.locked[0]:
93 94 h.flash(_('This repository has been locked by %s on %s')
94 95 % (h.person_by_id(repo.locked[0]),
95 96 h.format_date(h.time_to_datetime(repo.locked[1]))),
96 97 'warning')
97 98 files_url = h.route_path(
98 99 'repo_files:default_path',
99 100 repo_name=self.db_repo_name, commit_id='tip')
100 101 raise HTTPFound(files_url)
101 102
102 103 def check_branch_permission(self, branch_name):
103 104 _ = self.request.translate
104 105
105 106 rule, branch_perm = self._rhodecode_user.get_rule_and_branch_permission(
106 107 self.db_repo_name, branch_name)
107 108 if branch_perm and branch_perm not in ['branch.push', 'branch.push_force']:
108 109 h.flash(
109 110 _('Branch `{}` changes forbidden by rule {}.').format(branch_name, rule),
110 111 'warning')
111 112 files_url = h.route_path(
112 113 'repo_files:default_path',
113 114 repo_name=self.db_repo_name, commit_id='tip')
114 115 raise HTTPFound(files_url)
115 116
116 117 def _get_commit_and_path(self):
117 118 default_commit_id = self.db_repo.landing_rev[1]
118 119 default_f_path = '/'
119 120
120 121 commit_id = self.request.matchdict.get(
121 122 'commit_id', default_commit_id)
122 123 f_path = self._get_f_path(self.request.matchdict, default_f_path)
123 124 return commit_id, f_path
124 125
125 126 def _get_default_encoding(self, c):
126 127 enc_list = getattr(c, 'default_encodings', [])
127 128 return enc_list[0] if enc_list else 'UTF-8'
128 129
129 130 def _get_commit_or_redirect(self, commit_id, redirect_after=True):
130 131 """
131 132 This is a safe way to get commit. If an error occurs it redirects to
132 133 tip with proper message
133 134
134 135 :param commit_id: id of commit to fetch
135 136 :param redirect_after: toggle redirection
136 137 """
137 138 _ = self.request.translate
138 139
139 140 try:
140 141 return self.rhodecode_vcs_repo.get_commit(commit_id)
141 142 except EmptyRepositoryError:
142 143 if not redirect_after:
143 144 return None
144 145
145 146 _url = h.route_path(
146 147 'repo_files_add_file',
147 148 repo_name=self.db_repo_name, commit_id=0, f_path='',
148 149 _anchor='edit')
149 150
150 151 if h.HasRepoPermissionAny(
151 152 'repository.write', 'repository.admin')(self.db_repo_name):
152 153 add_new = h.link_to(
153 154 _('Click here to add a new file.'), _url, class_="alert-link")
154 155 else:
155 156 add_new = ""
156 157
157 158 h.flash(h.literal(
158 159 _('There are no files yet. %s') % add_new), category='warning')
159 160 raise HTTPFound(
160 161 h.route_path('repo_summary', repo_name=self.db_repo_name))
161 162
162 163 except (CommitDoesNotExistError, LookupError):
163 164 msg = _('No such commit exists for this repository')
164 165 h.flash(msg, category='error')
165 166 raise HTTPNotFound()
166 167 except RepositoryError as e:
167 168 h.flash(safe_str(h.escape(e)), category='error')
168 169 raise HTTPNotFound()
169 170
170 171 def _get_filenode_or_redirect(self, commit_obj, path):
171 172 """
172 173 Returns file_node, if error occurs or given path is directory,
173 174 it'll redirect to top level path
174 175 """
175 176 _ = self.request.translate
176 177
177 178 try:
178 179 file_node = commit_obj.get_node(path)
179 180 if file_node.is_dir():
180 181 raise RepositoryError('The given path is a directory')
181 182 except CommitDoesNotExistError:
182 183 log.exception('No such commit exists for this repository')
183 184 h.flash(_('No such commit exists for this repository'), category='error')
184 185 raise HTTPNotFound()
185 186 except RepositoryError as e:
186 187 log.warning('Repository error while fetching '
187 188 'filenode `%s`. Err:%s', path, e)
188 189 h.flash(safe_str(h.escape(e)), category='error')
189 190 raise HTTPNotFound()
190 191
191 192 return file_node
192 193
193 194 def _is_valid_head(self, commit_id, repo):
194 195 branch_name = sha_commit_id = ''
195 196 is_head = False
196 197
197 198 if h.is_svn(repo) and not repo.is_empty():
198 199 # Note: Subversion only has one head.
199 200 if commit_id == repo.get_commit(commit_idx=-1).raw_id:
200 201 is_head = True
201 202 return branch_name, sha_commit_id, is_head
202 203
203 204 for _branch_name, branch_commit_id in repo.branches.items():
204 205 # simple case we pass in branch name, it's a HEAD
205 206 if commit_id == _branch_name:
206 207 is_head = True
207 208 branch_name = _branch_name
208 209 sha_commit_id = branch_commit_id
209 210 break
210 211 # case when we pass in full sha commit_id, which is a head
211 212 elif commit_id == branch_commit_id:
212 213 is_head = True
213 214 branch_name = _branch_name
214 215 sha_commit_id = branch_commit_id
215 216 break
216 217
217 218 # checked branches, means we only need to try to get the branch/commit_sha
218 219 if not repo.is_empty:
219 220 commit = repo.get_commit(commit_id=commit_id)
220 221 if commit:
221 222 branch_name = commit.branch
222 223 sha_commit_id = commit.raw_id
223 224
224 225 return branch_name, sha_commit_id, is_head
225 226
226 227 def _get_tree_at_commit(
227 228 self, c, commit_id, f_path, full_load=False):
228 229
229 230 repo_id = self.db_repo.repo_id
230 231
231 232 cache_seconds = safe_int(
232 233 rhodecode.CONFIG.get('rc_cache.cache_repo.expiration_time'))
233 234 cache_on = cache_seconds > 0
234 235 log.debug(
235 236 'Computing FILE TREE for repo_id %s commit_id `%s` and path `%s`'
236 237 'with caching: %s[TTL: %ss]' % (
237 238 repo_id, commit_id, f_path, cache_on, cache_seconds or 0))
238 239
239 240 cache_namespace_uid = 'cache_repo.{}'.format(repo_id)
240 241 region = rc_cache.get_or_create_region('cache_repo', cache_namespace_uid)
241 242
242 243 @region.conditional_cache_on_arguments(namespace=cache_namespace_uid,
243 244 condition=cache_on)
244 245 def compute_file_tree(repo_id, commit_id, f_path, full_load):
245 246 log.debug('Generating cached file tree for repo_id: %s, %s, %s',
246 247 repo_id, commit_id, f_path)
247 248
248 249 c.full_load = full_load
249 250 return render(
250 251 'rhodecode:templates/files/files_browser_tree.mako',
251 252 self._get_template_context(c), self.request)
252 253
253 254 return compute_file_tree(self.db_repo.repo_id, commit_id, f_path, full_load)
254 255
255 256 def _get_archive_spec(self, fname):
256 257 log.debug('Detecting archive spec for: `%s`', fname)
257 258
258 259 fileformat = None
259 260 ext = None
260 261 content_type = None
261 262 for a_type, ext_data in settings.ARCHIVE_SPECS.items():
262 263 content_type, extension = ext_data
263 264
264 265 if fname.endswith(extension):
265 266 fileformat = a_type
266 267 log.debug('archive is of type: %s', fileformat)
267 268 ext = extension
268 269 break
269 270
270 271 if not fileformat:
271 272 raise ValueError()
272 273
273 274 # left over part of whole fname is the commit
274 275 commit_id = fname[:-len(ext)]
275 276
276 277 return commit_id, ext, fileformat, content_type
277 278
278 279 @LoginRequired()
279 280 @HasRepoPermissionAnyDecorator(
280 281 'repository.read', 'repository.write', 'repository.admin')
281 282 @view_config(
282 283 route_name='repo_archivefile', request_method='GET',
283 284 renderer=None)
284 285 def repo_archivefile(self):
285 286 # archive cache config
286 287 from rhodecode import CONFIG
287 288 _ = self.request.translate
288 289 self.load_default_context()
289 290
290 291 fname = self.request.matchdict['fname']
291 292 subrepos = self.request.GET.get('subrepos') == 'true'
292 293
293 294 if not self.db_repo.enable_downloads:
294 295 return Response(_('Downloads disabled'))
295 296
296 297 try:
297 298 commit_id, ext, fileformat, content_type = \
298 299 self._get_archive_spec(fname)
299 300 except ValueError:
300 301 return Response(_('Unknown archive type for: `{}`').format(
301 302 h.escape(fname)))
302 303
303 304 try:
304 305 commit = self.rhodecode_vcs_repo.get_commit(commit_id)
305 306 except CommitDoesNotExistError:
306 307 return Response(_('Unknown commit_id {}').format(
307 308 h.escape(commit_id)))
308 309 except EmptyRepositoryError:
309 310 return Response(_('Empty repository'))
310 311
311 312 archive_name = '%s-%s%s%s' % (
312 313 safe_str(self.db_repo_name.replace('/', '_')),
313 314 '-sub' if subrepos else '',
314 315 safe_str(commit.short_id), ext)
315 316
316 317 use_cached_archive = False
317 318 archive_cache_enabled = CONFIG.get(
318 319 'archive_cache_dir') and not self.request.GET.get('no_cache')
319 320 cached_archive_path = None
320 321
321 322 if archive_cache_enabled:
322 323 # check if we it's ok to write
323 324 if not os.path.isdir(CONFIG['archive_cache_dir']):
324 325 os.makedirs(CONFIG['archive_cache_dir'])
325 326 cached_archive_path = os.path.join(
326 327 CONFIG['archive_cache_dir'], archive_name)
327 328 if os.path.isfile(cached_archive_path):
328 329 log.debug('Found cached archive in %s', cached_archive_path)
329 330 fd, archive = None, cached_archive_path
330 331 use_cached_archive = True
331 332 else:
332 333 log.debug('Archive %s is not yet cached', archive_name)
333 334
334 335 if not use_cached_archive:
335 336 # generate new archive
336 337 fd, archive = tempfile.mkstemp()
337 338 log.debug('Creating new temp archive in %s', archive)
338 339 try:
339 340 commit.archive_repo(archive, kind=fileformat, subrepos=subrepos)
340 341 except ImproperArchiveTypeError:
341 342 return _('Unknown archive type')
342 343 if archive_cache_enabled:
343 344 # if we generated the archive and we have cache enabled
344 345 # let's use this for future
345 346 log.debug('Storing new archive in %s', cached_archive_path)
346 347 shutil.move(archive, cached_archive_path)
347 348 archive = cached_archive_path
348 349
349 350 # store download action
350 351 audit_logger.store_web(
351 352 'repo.archive.download', action_data={
352 353 'user_agent': self.request.user_agent,
353 354 'archive_name': archive_name,
354 355 'archive_spec': fname,
355 356 'archive_cached': use_cached_archive},
356 357 user=self._rhodecode_user,
357 358 repo=self.db_repo,
358 359 commit=True
359 360 )
360 361
361 362 def get_chunked_archive(archive_path):
362 363 with open(archive_path, 'rb') as stream:
363 364 while True:
364 365 data = stream.read(16 * 1024)
365 366 if not data:
366 367 if fd: # fd means we used temporary file
367 368 os.close(fd)
368 369 if not archive_cache_enabled:
369 370 log.debug('Destroying temp archive %s', archive_path)
370 371 os.remove(archive_path)
371 372 break
372 373 yield data
373 374
374 375 response = Response(app_iter=get_chunked_archive(archive))
375 376 response.content_disposition = str(
376 377 'attachment; filename=%s' % archive_name)
377 378 response.content_type = str(content_type)
378 379
379 380 return response
380 381
381 382 def _get_file_node(self, commit_id, f_path):
382 383 if commit_id not in ['', None, 'None', '0' * 12, '0' * 40]:
383 384 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
384 385 try:
385 386 node = commit.get_node(f_path)
386 387 if node.is_dir():
387 388 raise NodeError('%s path is a %s not a file'
388 389 % (node, type(node)))
389 390 except NodeDoesNotExistError:
390 391 commit = EmptyCommit(
391 392 commit_id=commit_id,
392 393 idx=commit.idx,
393 394 repo=commit.repository,
394 395 alias=commit.repository.alias,
395 396 message=commit.message,
396 397 author=commit.author,
397 398 date=commit.date)
398 399 node = FileNode(f_path, '', commit=commit)
399 400 else:
400 401 commit = EmptyCommit(
401 402 repo=self.rhodecode_vcs_repo,
402 403 alias=self.rhodecode_vcs_repo.alias)
403 404 node = FileNode(f_path, '', commit=commit)
404 405 return node
405 406
406 407 @LoginRequired()
407 408 @HasRepoPermissionAnyDecorator(
408 409 'repository.read', 'repository.write', 'repository.admin')
409 410 @view_config(
410 411 route_name='repo_files_diff', request_method='GET',
411 412 renderer=None)
412 413 def repo_files_diff(self):
413 414 c = self.load_default_context()
414 415 f_path = self._get_f_path(self.request.matchdict)
415 416 diff1 = self.request.GET.get('diff1', '')
416 417 diff2 = self.request.GET.get('diff2', '')
417 418
418 419 path1, diff1 = parse_path_ref(diff1, default_path=f_path)
419 420
420 421 ignore_whitespace = str2bool(self.request.GET.get('ignorews'))
421 422 line_context = self.request.GET.get('context', 3)
422 423
423 424 if not any((diff1, diff2)):
424 425 h.flash(
425 426 'Need query parameter "diff1" or "diff2" to generate a diff.',
426 427 category='error')
427 428 raise HTTPBadRequest()
428 429
429 430 c.action = self.request.GET.get('diff')
430 431 if c.action not in ['download', 'raw']:
431 432 compare_url = h.route_path(
432 433 'repo_compare',
433 434 repo_name=self.db_repo_name,
434 435 source_ref_type='rev',
435 436 source_ref=diff1,
436 437 target_repo=self.db_repo_name,
437 438 target_ref_type='rev',
438 439 target_ref=diff2,
439 440 _query=dict(f_path=f_path))
440 441 # redirect to new view if we render diff
441 442 raise HTTPFound(compare_url)
442 443
443 444 try:
444 445 node1 = self._get_file_node(diff1, path1)
445 446 node2 = self._get_file_node(diff2, f_path)
446 447 except (RepositoryError, NodeError):
447 448 log.exception("Exception while trying to get node from repository")
448 449 raise HTTPFound(
449 450 h.route_path('repo_files', repo_name=self.db_repo_name,
450 451 commit_id='tip', f_path=f_path))
451 452
452 453 if all(isinstance(node.commit, EmptyCommit)
453 454 for node in (node1, node2)):
454 455 raise HTTPNotFound()
455 456
456 457 c.commit_1 = node1.commit
457 458 c.commit_2 = node2.commit
458 459
459 460 if c.action == 'download':
460 461 _diff = diffs.get_gitdiff(node1, node2,
461 462 ignore_whitespace=ignore_whitespace,
462 463 context=line_context)
463 464 diff = diffs.DiffProcessor(_diff, format='gitdiff')
464 465
465 466 response = Response(self.path_filter.get_raw_patch(diff))
466 467 response.content_type = 'text/plain'
467 468 response.content_disposition = (
468 469 'attachment; filename=%s_%s_vs_%s.diff' % (f_path, diff1, diff2)
469 470 )
470 471 charset = self._get_default_encoding(c)
471 472 if charset:
472 473 response.charset = charset
473 474 return response
474 475
475 476 elif c.action == 'raw':
476 477 _diff = diffs.get_gitdiff(node1, node2,
477 478 ignore_whitespace=ignore_whitespace,
478 479 context=line_context)
479 480 diff = diffs.DiffProcessor(_diff, format='gitdiff')
480 481
481 482 response = Response(self.path_filter.get_raw_patch(diff))
482 483 response.content_type = 'text/plain'
483 484 charset = self._get_default_encoding(c)
484 485 if charset:
485 486 response.charset = charset
486 487 return response
487 488
488 489 # in case we ever end up here
489 490 raise HTTPNotFound()
490 491
491 492 @LoginRequired()
492 493 @HasRepoPermissionAnyDecorator(
493 494 'repository.read', 'repository.write', 'repository.admin')
494 495 @view_config(
495 496 route_name='repo_files_diff_2way_redirect', request_method='GET',
496 497 renderer=None)
497 498 def repo_files_diff_2way_redirect(self):
498 499 """
499 500 Kept only to make OLD links work
500 501 """
501 502 f_path = self._get_f_path_unchecked(self.request.matchdict)
502 503 diff1 = self.request.GET.get('diff1', '')
503 504 diff2 = self.request.GET.get('diff2', '')
504 505
505 506 if not any((diff1, diff2)):
506 507 h.flash(
507 508 'Need query parameter "diff1" or "diff2" to generate a diff.',
508 509 category='error')
509 510 raise HTTPBadRequest()
510 511
511 512 compare_url = h.route_path(
512 513 'repo_compare',
513 514 repo_name=self.db_repo_name,
514 515 source_ref_type='rev',
515 516 source_ref=diff1,
516 517 target_ref_type='rev',
517 518 target_ref=diff2,
518 519 _query=dict(f_path=f_path, diffmode='sideside',
519 520 target_repo=self.db_repo_name,))
520 521 raise HTTPFound(compare_url)
521 522
522 523 @LoginRequired()
523 524 @HasRepoPermissionAnyDecorator(
524 525 'repository.read', 'repository.write', 'repository.admin')
525 526 @view_config(
526 527 route_name='repo_files', request_method='GET',
527 528 renderer=None)
528 529 @view_config(
529 530 route_name='repo_files:default_path', request_method='GET',
530 531 renderer=None)
531 532 @view_config(
532 533 route_name='repo_files:default_commit', request_method='GET',
533 534 renderer=None)
534 535 @view_config(
535 536 route_name='repo_files:rendered', request_method='GET',
536 537 renderer=None)
537 538 @view_config(
538 539 route_name='repo_files:annotated', request_method='GET',
539 540 renderer=None)
540 541 def repo_files(self):
541 542 c = self.load_default_context()
542 543
543 544 view_name = getattr(self.request.matched_route, 'name', None)
544 545
545 546 c.annotate = view_name == 'repo_files:annotated'
546 547 # default is false, but .rst/.md files later are auto rendered, we can
547 548 # overwrite auto rendering by setting this GET flag
548 549 c.renderer = view_name == 'repo_files:rendered' or \
549 550 not self.request.GET.get('no-render', False)
550 551
551 552 # redirect to given commit_id from form if given
552 553 get_commit_id = self.request.GET.get('at_rev', None)
553 554 if get_commit_id:
554 555 self._get_commit_or_redirect(get_commit_id)
555 556
556 557 commit_id, f_path = self._get_commit_and_path()
557 558 c.commit = self._get_commit_or_redirect(commit_id)
558 559 c.branch = self.request.GET.get('branch', None)
559 560 c.f_path = f_path
560 561
561 562 # prev link
562 563 try:
563 564 prev_commit = c.commit.prev(c.branch)
564 565 c.prev_commit = prev_commit
565 566 c.url_prev = h.route_path(
566 567 'repo_files', repo_name=self.db_repo_name,
567 568 commit_id=prev_commit.raw_id, f_path=f_path)
568 569 if c.branch:
569 570 c.url_prev += '?branch=%s' % c.branch
570 571 except (CommitDoesNotExistError, VCSError):
571 572 c.url_prev = '#'
572 573 c.prev_commit = EmptyCommit()
573 574
574 575 # next link
575 576 try:
576 577 next_commit = c.commit.next(c.branch)
577 578 c.next_commit = next_commit
578 579 c.url_next = h.route_path(
579 580 'repo_files', repo_name=self.db_repo_name,
580 581 commit_id=next_commit.raw_id, f_path=f_path)
581 582 if c.branch:
582 583 c.url_next += '?branch=%s' % c.branch
583 584 except (CommitDoesNotExistError, VCSError):
584 585 c.url_next = '#'
585 586 c.next_commit = EmptyCommit()
586 587
587 588 # files or dirs
588 589 try:
589 590 c.file = c.commit.get_node(f_path)
590 591 c.file_author = True
591 592 c.file_tree = ''
592 593
593 594 # load file content
594 595 if c.file.is_file():
595 596 c.lf_node = c.file.get_largefile_node()
596 597
597 598 c.file_source_page = 'true'
598 599 c.file_last_commit = c.file.last_commit
599 600 if c.file.size < c.visual.cut_off_limit_diff:
600 601 if c.annotate: # annotation has precedence over renderer
601 602 c.annotated_lines = filenode_as_annotated_lines_tokens(
602 603 c.file
603 604 )
604 605 else:
605 606 c.renderer = (
606 607 c.renderer and h.renderer_from_filename(c.file.path)
607 608 )
608 609 if not c.renderer:
609 610 c.lines = filenode_as_lines_tokens(c.file)
610 611
611 612 _branch_name, _sha_commit_id, is_head = self._is_valid_head(
612 613 commit_id, self.rhodecode_vcs_repo)
613 614 c.on_branch_head = is_head
614 615
615 616 branch = c.commit.branch if (
616 617 c.commit.branch and '/' not in c.commit.branch) else None
617 618 c.branch_or_raw_id = branch or c.commit.raw_id
618 619 c.branch_name = c.commit.branch or h.short_id(c.commit.raw_id)
619 620
620 621 author = c.file_last_commit.author
621 622 c.authors = [[
622 623 h.email(author),
623 624 h.person(author, 'username_or_name_or_email'),
624 625 1
625 626 ]]
626 627
627 628 else: # load tree content at path
628 629 c.file_source_page = 'false'
629 630 c.authors = []
630 631 # this loads a simple tree without metadata to speed things up
631 632 # later via ajax we call repo_nodetree_full and fetch whole
632 633 c.file_tree = self._get_tree_at_commit(
633 634 c, c.commit.raw_id, f_path)
634 635
635 636 except RepositoryError as e:
636 637 h.flash(safe_str(h.escape(e)), category='error')
637 638 raise HTTPNotFound()
638 639
639 640 if self.request.environ.get('HTTP_X_PJAX'):
640 641 html = render('rhodecode:templates/files/files_pjax.mako',
641 642 self._get_template_context(c), self.request)
642 643 else:
643 644 html = render('rhodecode:templates/files/files.mako',
644 645 self._get_template_context(c), self.request)
645 646 return Response(html)
646 647
647 648 @HasRepoPermissionAnyDecorator(
648 649 'repository.read', 'repository.write', 'repository.admin')
649 650 @view_config(
650 651 route_name='repo_files:annotated_previous', request_method='GET',
651 652 renderer=None)
652 653 def repo_files_annotated_previous(self):
653 654 self.load_default_context()
654 655
655 656 commit_id, f_path = self._get_commit_and_path()
656 657 commit = self._get_commit_or_redirect(commit_id)
657 658 prev_commit_id = commit.raw_id
658 659 line_anchor = self.request.GET.get('line_anchor')
659 660 is_file = False
660 661 try:
661 662 _file = commit.get_node(f_path)
662 663 is_file = _file.is_file()
663 664 except (NodeDoesNotExistError, CommitDoesNotExistError, VCSError):
664 665 pass
665 666
666 667 if is_file:
667 668 history = commit.get_path_history(f_path)
668 669 prev_commit_id = history[1].raw_id \
669 670 if len(history) > 1 else prev_commit_id
670 671 prev_url = h.route_path(
671 672 'repo_files:annotated', repo_name=self.db_repo_name,
672 673 commit_id=prev_commit_id, f_path=f_path,
673 674 _anchor='L{}'.format(line_anchor))
674 675
675 676 raise HTTPFound(prev_url)
676 677
677 678 @LoginRequired()
678 679 @HasRepoPermissionAnyDecorator(
679 680 'repository.read', 'repository.write', 'repository.admin')
680 681 @view_config(
681 682 route_name='repo_nodetree_full', request_method='GET',
682 683 renderer=None, xhr=True)
683 684 @view_config(
684 685 route_name='repo_nodetree_full:default_path', request_method='GET',
685 686 renderer=None, xhr=True)
686 687 def repo_nodetree_full(self):
687 688 """
688 689 Returns rendered html of file tree that contains commit date,
689 690 author, commit_id for the specified combination of
690 691 repo, commit_id and file path
691 692 """
692 693 c = self.load_default_context()
693 694
694 695 commit_id, f_path = self._get_commit_and_path()
695 696 commit = self._get_commit_or_redirect(commit_id)
696 697 try:
697 698 dir_node = commit.get_node(f_path)
698 699 except RepositoryError as e:
699 700 return Response('error: {}'.format(h.escape(safe_str(e))))
700 701
701 702 if dir_node.is_file():
702 703 return Response('')
703 704
704 705 c.file = dir_node
705 706 c.commit = commit
706 707
707 708 html = self._get_tree_at_commit(
708 709 c, commit.raw_id, dir_node.path, full_load=True)
709 710
710 711 return Response(html)
711 712
712 def _get_attachement_disposition(self, f_path):
713 return 'attachment; filename=%s' % \
714 safe_str(f_path.split(Repository.NAME_SEP)[-1])
713 def _get_attachement_headers(self, f_path):
714 f_name = safe_str(f_path.split(Repository.NAME_SEP)[-1])
715 safe_path = f_name.replace('"', '\\"')
716 encoded_path = urllib.quote(f_name)
717
718 return "attachment; " \
719 "filename=\"{}\"; " \
720 "filename*=UTF-8\'\'{}".format(safe_path, encoded_path)
715 721
716 722 @LoginRequired()
717 723 @HasRepoPermissionAnyDecorator(
718 724 'repository.read', 'repository.write', 'repository.admin')
719 725 @view_config(
720 726 route_name='repo_file_raw', request_method='GET',
721 727 renderer=None)
722 728 def repo_file_raw(self):
723 729 """
724 730 Action for show as raw, some mimetypes are "rendered",
725 731 those include images, icons.
726 732 """
727 733 c = self.load_default_context()
728 734
729 735 commit_id, f_path = self._get_commit_and_path()
730 736 commit = self._get_commit_or_redirect(commit_id)
731 737 file_node = self._get_filenode_or_redirect(commit, f_path)
732 738
733 739 raw_mimetype_mapping = {
734 740 # map original mimetype to a mimetype used for "show as raw"
735 741 # you can also provide a content-disposition to override the
736 742 # default "attachment" disposition.
737 743 # orig_type: (new_type, new_dispo)
738 744
739 745 # show images inline:
740 746 # Do not re-add SVG: it is unsafe and permits XSS attacks. One can
741 747 # for example render an SVG with javascript inside or even render
742 748 # HTML.
743 749 'image/x-icon': ('image/x-icon', 'inline'),
744 750 'image/png': ('image/png', 'inline'),
745 751 'image/gif': ('image/gif', 'inline'),
746 752 'image/jpeg': ('image/jpeg', 'inline'),
747 753 'application/pdf': ('application/pdf', 'inline'),
748 754 }
749 755
750 756 mimetype = file_node.mimetype
751 757 try:
752 758 mimetype, disposition = raw_mimetype_mapping[mimetype]
753 759 except KeyError:
754 760 # we don't know anything special about this, handle it safely
755 761 if file_node.is_binary:
756 762 # do same as download raw for binary files
757 763 mimetype, disposition = 'application/octet-stream', 'attachment'
758 764 else:
759 765 # do not just use the original mimetype, but force text/plain,
760 766 # otherwise it would serve text/html and that might be unsafe.
761 767 # Note: underlying vcs library fakes text/plain mimetype if the
762 768 # mimetype can not be determined and it thinks it is not
763 769 # binary.This might lead to erroneous text display in some
764 770 # cases, but helps in other cases, like with text files
765 771 # without extension.
766 772 mimetype, disposition = 'text/plain', 'inline'
767 773
768 774 if disposition == 'attachment':
769 disposition = self._get_attachement_disposition(f_path)
775 disposition = self._get_attachement_headers(f_path)
770 776
771 777 def stream_node():
772 778 yield file_node.raw_bytes
773 779
774 780 response = Response(app_iter=stream_node())
775 781 response.content_disposition = disposition
776 782 response.content_type = mimetype
777 783
778 784 charset = self._get_default_encoding(c)
779 785 if charset:
780 786 response.charset = charset
781 787
782 788 return response
783 789
784 790 @LoginRequired()
785 791 @HasRepoPermissionAnyDecorator(
786 792 'repository.read', 'repository.write', 'repository.admin')
787 793 @view_config(
788 794 route_name='repo_file_download', request_method='GET',
789 795 renderer=None)
790 796 @view_config(
791 797 route_name='repo_file_download:legacy', request_method='GET',
792 798 renderer=None)
793 799 def repo_file_download(self):
794 800 c = self.load_default_context()
795 801
796 802 commit_id, f_path = self._get_commit_and_path()
797 803 commit = self._get_commit_or_redirect(commit_id)
798 804 file_node = self._get_filenode_or_redirect(commit, f_path)
799 805
800 806 if self.request.GET.get('lf'):
801 807 # only if lf get flag is passed, we download this file
802 808 # as LFS/Largefile
803 809 lf_node = file_node.get_largefile_node()
804 810 if lf_node:
805 811 # overwrite our pointer with the REAL large-file
806 812 file_node = lf_node
807 813
808 disposition = self._get_attachement_disposition(f_path)
814 disposition = self._get_attachement_headers(f_path)
809 815
810 816 def stream_node():
811 817 yield file_node.raw_bytes
812 818
813 819 response = Response(app_iter=stream_node())
814 820 response.content_disposition = disposition
815 821 response.content_type = file_node.mimetype
816 822
817 823 charset = self._get_default_encoding(c)
818 824 if charset:
819 825 response.charset = charset
820 826
821 827 return response
822 828
823 829 def _get_nodelist_at_commit(self, repo_name, repo_id, commit_id, f_path):
824 830
825 831 cache_seconds = safe_int(
826 832 rhodecode.CONFIG.get('rc_cache.cache_repo.expiration_time'))
827 833 cache_on = cache_seconds > 0
828 834 log.debug(
829 835 'Computing FILE SEARCH for repo_id %s commit_id `%s` and path `%s`'
830 836 'with caching: %s[TTL: %ss]' % (
831 837 repo_id, commit_id, f_path, cache_on, cache_seconds or 0))
832 838
833 839 cache_namespace_uid = 'cache_repo.{}'.format(repo_id)
834 840 region = rc_cache.get_or_create_region('cache_repo', cache_namespace_uid)
835 841
836 842 @region.conditional_cache_on_arguments(namespace=cache_namespace_uid,
837 843 condition=cache_on)
838 844 def compute_file_search(repo_id, commit_id, f_path):
839 845 log.debug('Generating cached nodelist for repo_id:%s, %s, %s',
840 846 repo_id, commit_id, f_path)
841 847 try:
842 848 _d, _f = ScmModel().get_nodes(
843 849 repo_name, commit_id, f_path, flat=False)
844 850 except (RepositoryError, CommitDoesNotExistError, Exception) as e:
845 851 log.exception(safe_str(e))
846 852 h.flash(safe_str(h.escape(e)), category='error')
847 853 raise HTTPFound(h.route_path(
848 854 'repo_files', repo_name=self.db_repo_name,
849 855 commit_id='tip', f_path='/'))
850 856 return _d + _f
851 857
852 858 return compute_file_search(self.db_repo.repo_id, commit_id, f_path)
853 859
854 860 @LoginRequired()
855 861 @HasRepoPermissionAnyDecorator(
856 862 'repository.read', 'repository.write', 'repository.admin')
857 863 @view_config(
858 864 route_name='repo_files_nodelist', request_method='GET',
859 865 renderer='json_ext', xhr=True)
860 866 def repo_nodelist(self):
861 867 self.load_default_context()
862 868
863 869 commit_id, f_path = self._get_commit_and_path()
864 870 commit = self._get_commit_or_redirect(commit_id)
865 871
866 872 metadata = self._get_nodelist_at_commit(
867 873 self.db_repo_name, self.db_repo.repo_id, commit.raw_id, f_path)
868 874 return {'nodes': metadata}
869 875
870 876 def _create_references(
871 877 self, branches_or_tags, symbolic_reference, f_path):
872 878 items = []
873 879 for name, commit_id in branches_or_tags.items():
874 880 sym_ref = symbolic_reference(commit_id, name, f_path)
875 881 items.append((sym_ref, name))
876 882 return items
877 883
878 884 def _symbolic_reference(self, commit_id, name, f_path):
879 885 return commit_id
880 886
881 887 def _symbolic_reference_svn(self, commit_id, name, f_path):
882 888 new_f_path = vcspath.join(name, f_path)
883 889 return u'%s@%s' % (new_f_path, commit_id)
884 890
885 891 def _get_node_history(self, commit_obj, f_path, commits=None):
886 892 """
887 893 get commit history for given node
888 894
889 895 :param commit_obj: commit to calculate history
890 896 :param f_path: path for node to calculate history for
891 897 :param commits: if passed don't calculate history and take
892 898 commits defined in this list
893 899 """
894 900 _ = self.request.translate
895 901
896 902 # calculate history based on tip
897 903 tip = self.rhodecode_vcs_repo.get_commit()
898 904 if commits is None:
899 905 pre_load = ["author", "branch"]
900 906 try:
901 907 commits = tip.get_path_history(f_path, pre_load=pre_load)
902 908 except (NodeDoesNotExistError, CommitError):
903 909 # this node is not present at tip!
904 910 commits = commit_obj.get_path_history(f_path, pre_load=pre_load)
905 911
906 912 history = []
907 913 commits_group = ([], _("Changesets"))
908 914 for commit in commits:
909 915 branch = ' (%s)' % commit.branch if commit.branch else ''
910 916 n_desc = 'r%s:%s%s' % (commit.idx, commit.short_id, branch)
911 917 commits_group[0].append((commit.raw_id, n_desc,))
912 918 history.append(commits_group)
913 919
914 920 symbolic_reference = self._symbolic_reference
915 921
916 922 if self.rhodecode_vcs_repo.alias == 'svn':
917 923 adjusted_f_path = RepoFilesView.adjust_file_path_for_svn(
918 924 f_path, self.rhodecode_vcs_repo)
919 925 if adjusted_f_path != f_path:
920 926 log.debug(
921 927 'Recognized svn tag or branch in file "%s", using svn '
922 928 'specific symbolic references', f_path)
923 929 f_path = adjusted_f_path
924 930 symbolic_reference = self._symbolic_reference_svn
925 931
926 932 branches = self._create_references(
927 933 self.rhodecode_vcs_repo.branches, symbolic_reference, f_path)
928 934 branches_group = (branches, _("Branches"))
929 935
930 936 tags = self._create_references(
931 937 self.rhodecode_vcs_repo.tags, symbolic_reference, f_path)
932 938 tags_group = (tags, _("Tags"))
933 939
934 940 history.append(branches_group)
935 941 history.append(tags_group)
936 942
937 943 return history, commits
938 944
939 945 @LoginRequired()
940 946 @HasRepoPermissionAnyDecorator(
941 947 'repository.read', 'repository.write', 'repository.admin')
942 948 @view_config(
943 949 route_name='repo_file_history', request_method='GET',
944 950 renderer='json_ext')
945 951 def repo_file_history(self):
946 952 self.load_default_context()
947 953
948 954 commit_id, f_path = self._get_commit_and_path()
949 955 commit = self._get_commit_or_redirect(commit_id)
950 956 file_node = self._get_filenode_or_redirect(commit, f_path)
951 957
952 958 if file_node.is_file():
953 959 file_history, _hist = self._get_node_history(commit, f_path)
954 960
955 961 res = []
956 962 for obj in file_history:
957 963 res.append({
958 964 'text': obj[1],
959 965 'children': [{'id': o[0], 'text': o[1]} for o in obj[0]]
960 966 })
961 967
962 968 data = {
963 969 'more': False,
964 970 'results': res
965 971 }
966 972 return data
967 973
968 974 log.warning('Cannot fetch history for directory')
969 975 raise HTTPBadRequest()
970 976
971 977 @LoginRequired()
972 978 @HasRepoPermissionAnyDecorator(
973 979 'repository.read', 'repository.write', 'repository.admin')
974 980 @view_config(
975 981 route_name='repo_file_authors', request_method='GET',
976 982 renderer='rhodecode:templates/files/file_authors_box.mako')
977 983 def repo_file_authors(self):
978 984 c = self.load_default_context()
979 985
980 986 commit_id, f_path = self._get_commit_and_path()
981 987 commit = self._get_commit_or_redirect(commit_id)
982 988 file_node = self._get_filenode_or_redirect(commit, f_path)
983 989
984 990 if not file_node.is_file():
985 991 raise HTTPBadRequest()
986 992
987 993 c.file_last_commit = file_node.last_commit
988 994 if self.request.GET.get('annotate') == '1':
989 995 # use _hist from annotation if annotation mode is on
990 996 commit_ids = set(x[1] for x in file_node.annotate)
991 997 _hist = (
992 998 self.rhodecode_vcs_repo.get_commit(commit_id)
993 999 for commit_id in commit_ids)
994 1000 else:
995 1001 _f_history, _hist = self._get_node_history(commit, f_path)
996 1002 c.file_author = False
997 1003
998 1004 unique = collections.OrderedDict()
999 1005 for commit in _hist:
1000 1006 author = commit.author
1001 1007 if author not in unique:
1002 1008 unique[commit.author] = [
1003 1009 h.email(author),
1004 1010 h.person(author, 'username_or_name_or_email'),
1005 1011 1 # counter
1006 1012 ]
1007 1013
1008 1014 else:
1009 1015 # increase counter
1010 1016 unique[commit.author][2] += 1
1011 1017
1012 1018 c.authors = [val for val in unique.values()]
1013 1019
1014 1020 return self._get_template_context(c)
1015 1021
1016 1022 @LoginRequired()
1017 1023 @HasRepoPermissionAnyDecorator('repository.write', 'repository.admin')
1018 1024 @view_config(
1019 1025 route_name='repo_files_remove_file', request_method='GET',
1020 1026 renderer='rhodecode:templates/files/files_delete.mako')
1021 1027 def repo_files_remove_file(self):
1022 1028 _ = self.request.translate
1023 1029 c = self.load_default_context()
1024 1030 commit_id, f_path = self._get_commit_and_path()
1025 1031
1026 1032 self._ensure_not_locked()
1027 1033 _branch_name, _sha_commit_id, is_head = \
1028 1034 self._is_valid_head(commit_id, self.rhodecode_vcs_repo)
1029 1035
1030 1036 if not is_head:
1031 1037 h.flash(_('You can only delete files with commit '
1032 1038 'being a valid branch head.'), category='warning')
1033 1039 raise HTTPFound(
1034 1040 h.route_path('repo_files',
1035 1041 repo_name=self.db_repo_name, commit_id='tip',
1036 1042 f_path=f_path))
1037 1043
1038 1044 self.check_branch_permission(_branch_name)
1039 1045 c.commit = self._get_commit_or_redirect(commit_id)
1040 1046 c.file = self._get_filenode_or_redirect(c.commit, f_path)
1041 1047
1042 1048 c.default_message = _(
1043 1049 'Deleted file {} via RhodeCode Enterprise').format(f_path)
1044 1050 c.f_path = f_path
1045 1051
1046 1052 return self._get_template_context(c)
1047 1053
1048 1054 @LoginRequired()
1049 1055 @HasRepoPermissionAnyDecorator('repository.write', 'repository.admin')
1050 1056 @CSRFRequired()
1051 1057 @view_config(
1052 1058 route_name='repo_files_delete_file', request_method='POST',
1053 1059 renderer=None)
1054 1060 def repo_files_delete_file(self):
1055 1061 _ = self.request.translate
1056 1062
1057 1063 c = self.load_default_context()
1058 1064 commit_id, f_path = self._get_commit_and_path()
1059 1065
1060 1066 self._ensure_not_locked()
1061 1067 _branch_name, _sha_commit_id, is_head = \
1062 1068 self._is_valid_head(commit_id, self.rhodecode_vcs_repo)
1063 1069
1064 1070 if not is_head:
1065 1071 h.flash(_('You can only delete files with commit '
1066 1072 'being a valid branch head.'), category='warning')
1067 1073 raise HTTPFound(
1068 1074 h.route_path('repo_files',
1069 1075 repo_name=self.db_repo_name, commit_id='tip',
1070 1076 f_path=f_path))
1071 1077 self.check_branch_permission(_branch_name)
1072 1078
1073 1079 c.commit = self._get_commit_or_redirect(commit_id)
1074 1080 c.file = self._get_filenode_or_redirect(c.commit, f_path)
1075 1081
1076 1082 c.default_message = _(
1077 1083 'Deleted file {} via RhodeCode Enterprise').format(f_path)
1078 1084 c.f_path = f_path
1079 1085 node_path = f_path
1080 1086 author = self._rhodecode_db_user.full_contact
1081 1087 message = self.request.POST.get('message') or c.default_message
1082 1088 try:
1083 1089 nodes = {
1084 1090 node_path: {
1085 1091 'content': ''
1086 1092 }
1087 1093 }
1088 1094 ScmModel().delete_nodes(
1089 1095 user=self._rhodecode_db_user.user_id, repo=self.db_repo,
1090 1096 message=message,
1091 1097 nodes=nodes,
1092 1098 parent_commit=c.commit,
1093 1099 author=author,
1094 1100 )
1095 1101
1096 1102 h.flash(
1097 1103 _('Successfully deleted file `{}`').format(
1098 1104 h.escape(f_path)), category='success')
1099 1105 except Exception:
1100 1106 log.exception('Error during commit operation')
1101 1107 h.flash(_('Error occurred during commit'), category='error')
1102 1108 raise HTTPFound(
1103 1109 h.route_path('repo_commit', repo_name=self.db_repo_name,
1104 1110 commit_id='tip'))
1105 1111
1106 1112 @LoginRequired()
1107 1113 @HasRepoPermissionAnyDecorator('repository.write', 'repository.admin')
1108 1114 @view_config(
1109 1115 route_name='repo_files_edit_file', request_method='GET',
1110 1116 renderer='rhodecode:templates/files/files_edit.mako')
1111 1117 def repo_files_edit_file(self):
1112 1118 _ = self.request.translate
1113 1119 c = self.load_default_context()
1114 1120 commit_id, f_path = self._get_commit_and_path()
1115 1121
1116 1122 self._ensure_not_locked()
1117 1123 _branch_name, _sha_commit_id, is_head = \
1118 1124 self._is_valid_head(commit_id, self.rhodecode_vcs_repo)
1119 1125
1120 1126 if not is_head:
1121 1127 h.flash(_('You can only edit files with commit '
1122 1128 'being a valid branch head.'), category='warning')
1123 1129 raise HTTPFound(
1124 1130 h.route_path('repo_files',
1125 1131 repo_name=self.db_repo_name, commit_id='tip',
1126 1132 f_path=f_path))
1127 1133 self.check_branch_permission(_branch_name)
1128 1134
1129 1135 c.commit = self._get_commit_or_redirect(commit_id)
1130 1136 c.file = self._get_filenode_or_redirect(c.commit, f_path)
1131 1137
1132 1138 if c.file.is_binary:
1133 1139 files_url = h.route_path(
1134 1140 'repo_files',
1135 1141 repo_name=self.db_repo_name,
1136 1142 commit_id=c.commit.raw_id, f_path=f_path)
1137 1143 raise HTTPFound(files_url)
1138 1144
1139 1145 c.default_message = _(
1140 1146 'Edited file {} via RhodeCode Enterprise').format(f_path)
1141 1147 c.f_path = f_path
1142 1148
1143 1149 return self._get_template_context(c)
1144 1150
1145 1151 @LoginRequired()
1146 1152 @HasRepoPermissionAnyDecorator('repository.write', 'repository.admin')
1147 1153 @CSRFRequired()
1148 1154 @view_config(
1149 1155 route_name='repo_files_update_file', request_method='POST',
1150 1156 renderer=None)
1151 1157 def repo_files_update_file(self):
1152 1158 _ = self.request.translate
1153 1159 c = self.load_default_context()
1154 1160 commit_id, f_path = self._get_commit_and_path()
1155 1161
1156 1162 self._ensure_not_locked()
1157 1163 _branch_name, _sha_commit_id, is_head = \
1158 1164 self._is_valid_head(commit_id, self.rhodecode_vcs_repo)
1159 1165
1160 1166 if not is_head:
1161 1167 h.flash(_('You can only edit files with commit '
1162 1168 'being a valid branch head.'), category='warning')
1163 1169 raise HTTPFound(
1164 1170 h.route_path('repo_files',
1165 1171 repo_name=self.db_repo_name, commit_id='tip',
1166 1172 f_path=f_path))
1167 1173
1168 1174 self.check_branch_permission(_branch_name)
1169 1175
1170 1176 c.commit = self._get_commit_or_redirect(commit_id)
1171 1177 c.file = self._get_filenode_or_redirect(c.commit, f_path)
1172 1178
1173 1179 if c.file.is_binary:
1174 1180 raise HTTPFound(
1175 1181 h.route_path('repo_files',
1176 1182 repo_name=self.db_repo_name,
1177 1183 commit_id=c.commit.raw_id,
1178 1184 f_path=f_path))
1179 1185
1180 1186 c.default_message = _(
1181 1187 'Edited file {} via RhodeCode Enterprise').format(f_path)
1182 1188 c.f_path = f_path
1183 1189 old_content = c.file.content
1184 1190 sl = old_content.splitlines(1)
1185 1191 first_line = sl[0] if sl else ''
1186 1192
1187 1193 r_post = self.request.POST
1188 1194 # modes: 0 - Unix, 1 - Mac, 2 - DOS
1189 1195 mode = detect_mode(first_line, 0)
1190 1196 content = convert_line_endings(r_post.get('content', ''), mode)
1191 1197
1192 1198 message = r_post.get('message') or c.default_message
1193 1199 org_f_path = c.file.unicode_path
1194 1200 filename = r_post['filename']
1195 1201 org_filename = c.file.name
1196 1202
1197 1203 if content == old_content and filename == org_filename:
1198 1204 h.flash(_('No changes'), category='warning')
1199 1205 raise HTTPFound(
1200 1206 h.route_path('repo_commit', repo_name=self.db_repo_name,
1201 1207 commit_id='tip'))
1202 1208 try:
1203 1209 mapping = {
1204 1210 org_f_path: {
1205 1211 'org_filename': org_f_path,
1206 1212 'filename': os.path.join(c.file.dir_path, filename),
1207 1213 'content': content,
1208 1214 'lexer': '',
1209 1215 'op': 'mod',
1210 1216 }
1211 1217 }
1212 1218
1213 1219 ScmModel().update_nodes(
1214 1220 user=self._rhodecode_db_user.user_id,
1215 1221 repo=self.db_repo,
1216 1222 message=message,
1217 1223 nodes=mapping,
1218 1224 parent_commit=c.commit,
1219 1225 )
1220 1226
1221 1227 h.flash(
1222 1228 _('Successfully committed changes to file `{}`').format(
1223 1229 h.escape(f_path)), category='success')
1224 1230 except Exception:
1225 1231 log.exception('Error occurred during commit')
1226 1232 h.flash(_('Error occurred during commit'), category='error')
1227 1233 raise HTTPFound(
1228 1234 h.route_path('repo_commit', repo_name=self.db_repo_name,
1229 1235 commit_id='tip'))
1230 1236
1231 1237 @LoginRequired()
1232 1238 @HasRepoPermissionAnyDecorator('repository.write', 'repository.admin')
1233 1239 @view_config(
1234 1240 route_name='repo_files_add_file', request_method='GET',
1235 1241 renderer='rhodecode:templates/files/files_add.mako')
1236 1242 def repo_files_add_file(self):
1237 1243 _ = self.request.translate
1238 1244 c = self.load_default_context()
1239 1245 commit_id, f_path = self._get_commit_and_path()
1240 1246
1241 1247 self._ensure_not_locked()
1242 1248
1243 1249 c.commit = self._get_commit_or_redirect(commit_id, redirect_after=False)
1244 1250 if c.commit is None:
1245 1251 c.commit = EmptyCommit(alias=self.rhodecode_vcs_repo.alias)
1246 1252 c.default_message = (_('Added file via RhodeCode Enterprise'))
1247 1253 c.f_path = f_path.lstrip('/') # ensure not relative path
1248 1254
1249 1255 if self.rhodecode_vcs_repo.is_empty:
1250 1256 # for empty repository we cannot check for current branch, we rely on
1251 1257 # c.commit.branch instead
1252 1258 _branch_name = c.commit.branch
1253 1259 is_head = True
1254 1260 else:
1255 1261 _branch_name, _sha_commit_id, is_head = \
1256 1262 self._is_valid_head(commit_id, self.rhodecode_vcs_repo)
1257 1263
1258 1264 if not is_head:
1259 1265 h.flash(_('You can only add files with commit '
1260 1266 'being a valid branch head.'), category='warning')
1261 1267 raise HTTPFound(
1262 1268 h.route_path('repo_files',
1263 1269 repo_name=self.db_repo_name, commit_id='tip',
1264 1270 f_path=f_path))
1265 1271
1266 1272 self.check_branch_permission(_branch_name)
1267 1273
1268 1274 return self._get_template_context(c)
1269 1275
1270 1276 @LoginRequired()
1271 1277 @HasRepoPermissionAnyDecorator('repository.write', 'repository.admin')
1272 1278 @CSRFRequired()
1273 1279 @view_config(
1274 1280 route_name='repo_files_create_file', request_method='POST',
1275 1281 renderer=None)
1276 1282 def repo_files_create_file(self):
1277 1283 _ = self.request.translate
1278 1284 c = self.load_default_context()
1279 1285 commit_id, f_path = self._get_commit_and_path()
1280 1286
1281 1287 self._ensure_not_locked()
1282 1288
1283 1289 r_post = self.request.POST
1284 1290
1285 1291 c.commit = self._get_commit_or_redirect(
1286 1292 commit_id, redirect_after=False)
1287 1293 if c.commit is None:
1288 1294 c.commit = EmptyCommit(alias=self.rhodecode_vcs_repo.alias)
1289 1295
1290 1296 if self.rhodecode_vcs_repo.is_empty:
1291 1297 # for empty repository we cannot check for current branch, we rely on
1292 1298 # c.commit.branch instead
1293 1299 _branch_name = c.commit.branch
1294 1300 is_head = True
1295 1301 else:
1296 1302 _branch_name, _sha_commit_id, is_head = \
1297 1303 self._is_valid_head(commit_id, self.rhodecode_vcs_repo)
1298 1304
1299 1305 if not is_head:
1300 1306 h.flash(_('You can only add files with commit '
1301 1307 'being a valid branch head.'), category='warning')
1302 1308 raise HTTPFound(
1303 1309 h.route_path('repo_files',
1304 1310 repo_name=self.db_repo_name, commit_id='tip',
1305 1311 f_path=f_path))
1306 1312
1307 1313 self.check_branch_permission(_branch_name)
1308 1314
1309 1315 c.default_message = (_('Added file via RhodeCode Enterprise'))
1310 1316 c.f_path = f_path
1311 1317 unix_mode = 0
1312 1318 content = convert_line_endings(r_post.get('content', ''), unix_mode)
1313 1319
1314 1320 message = r_post.get('message') or c.default_message
1315 1321 filename = r_post.get('filename')
1316 1322 location = r_post.get('location', '') # dir location
1317 1323 file_obj = r_post.get('upload_file', None)
1318 1324
1319 1325 if file_obj is not None and hasattr(file_obj, 'filename'):
1320 1326 filename = r_post.get('filename_upload')
1321 1327 content = file_obj.file
1322 1328
1323 1329 if hasattr(content, 'file'):
1324 1330 # non posix systems store real file under file attr
1325 1331 content = content.file
1326 1332
1327 1333 if self.rhodecode_vcs_repo.is_empty:
1328 1334 default_redirect_url = h.route_path(
1329 1335 'repo_summary', repo_name=self.db_repo_name)
1330 1336 else:
1331 1337 default_redirect_url = h.route_path(
1332 1338 'repo_commit', repo_name=self.db_repo_name, commit_id='tip')
1333 1339
1334 1340 # If there's no commit, redirect to repo summary
1335 1341 if type(c.commit) is EmptyCommit:
1336 1342 redirect_url = h.route_path(
1337 1343 'repo_summary', repo_name=self.db_repo_name)
1338 1344 else:
1339 1345 redirect_url = default_redirect_url
1340 1346
1341 1347 if not filename:
1342 1348 h.flash(_('No filename'), category='warning')
1343 1349 raise HTTPFound(redirect_url)
1344 1350
1345 1351 # extract the location from filename,
1346 1352 # allows using foo/bar.txt syntax to create subdirectories
1347 1353 subdir_loc = filename.rsplit('/', 1)
1348 1354 if len(subdir_loc) == 2:
1349 1355 location = os.path.join(location, subdir_loc[0])
1350 1356
1351 1357 # strip all crap out of file, just leave the basename
1352 1358 filename = os.path.basename(filename)
1353 1359 node_path = os.path.join(location, filename)
1354 1360 author = self._rhodecode_db_user.full_contact
1355 1361
1356 1362 try:
1357 1363 nodes = {
1358 1364 node_path: {
1359 1365 'content': content
1360 1366 }
1361 1367 }
1362 1368 ScmModel().create_nodes(
1363 1369 user=self._rhodecode_db_user.user_id,
1364 1370 repo=self.db_repo,
1365 1371 message=message,
1366 1372 nodes=nodes,
1367 1373 parent_commit=c.commit,
1368 1374 author=author,
1369 1375 )
1370 1376
1371 1377 h.flash(
1372 1378 _('Successfully committed new file `{}`').format(
1373 1379 h.escape(node_path)), category='success')
1374 1380 except NonRelativePathError:
1375 1381 log.exception('Non Relative path found')
1376 1382 h.flash(_(
1377 1383 'The location specified must be a relative path and must not '
1378 1384 'contain .. in the path'), category='warning')
1379 1385 raise HTTPFound(default_redirect_url)
1380 1386 except (NodeError, NodeAlreadyExistsError) as e:
1381 1387 h.flash(_(h.escape(e)), category='error')
1382 1388 except Exception:
1383 1389 log.exception('Error occurred during commit')
1384 1390 h.flash(_('Error occurred during commit'), category='error')
1385 1391
1386 1392 raise HTTPFound(default_redirect_url)
@@ -1,225 +1,226 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
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 20
21 21 import base64
22 22 import logging
23 23 import urllib
24 24 import urlparse
25 25
26 26 import requests
27 27 from pyramid.httpexceptions import HTTPNotAcceptable
28 28
29 29 from rhodecode.lib import rc_cache
30 30 from rhodecode.lib.middleware import simplevcs
31 31 from rhodecode.lib.utils import is_valid_repo
32 32 from rhodecode.lib.utils2 import str2bool, safe_int
33 33 from rhodecode.lib.ext_json import json
34 34 from rhodecode.lib.hooks_daemon import store_txn_id_data
35 35
36 36
37 37 log = logging.getLogger(__name__)
38 38
39 39
40 40 class SimpleSvnApp(object):
41 41 IGNORED_HEADERS = [
42 42 'connection', 'keep-alive', 'content-encoding',
43 43 'transfer-encoding', 'content-length']
44 44 rc_extras = {}
45 45
46 46 def __init__(self, config):
47 47 self.config = config
48 48
49 49 def __call__(self, environ, start_response):
50 50 request_headers = self._get_request_headers(environ)
51 51 data = environ['wsgi.input']
52 52 req_method = environ['REQUEST_METHOD']
53 53 has_content_length = 'CONTENT_LENGTH' in environ
54 path_info = self._get_url(environ['PATH_INFO'])
54 path_info = self._get_url(
55 self.config.get('subversion_http_server_url', ''), environ['PATH_INFO'])
55 56 transfer_encoding = environ.get('HTTP_TRANSFER_ENCODING', '')
56 57 log.debug('Handling: %s method via `%s`', req_method, path_info)
57 58
58 59 # stream control flag, based on request and content type...
59 60 stream = False
60 61
61 62 if req_method in ['MKCOL'] or has_content_length:
62 63 data_processed = False
63 64 # read chunk to check if we have txn-with-props
64 65 initial_data = data.read(1024)
65 66 if initial_data.startswith('(create-txn-with-props'):
66 67 data = initial_data + data.read()
67 68 # store on-the-fly our rc_extra using svn revision properties
68 69 # those can be read later on in hooks executed so we have a way
69 70 # to pass in the data into svn hooks
70 71 rc_data = base64.urlsafe_b64encode(json.dumps(self.rc_extras))
71 72 rc_data_len = len(rc_data)
72 73 # header defines data length, and serialized data
73 74 skel = ' rc-scm-extras {} {}'.format(rc_data_len, rc_data)
74 75 data = data[:-2] + skel + '))'
75 76 data_processed = True
76 77
77 78 if not data_processed:
78 79 # NOTE(johbo): Avoid that we end up with sending the request in chunked
79 80 # transfer encoding (mainly on Gunicorn). If we know the content
80 81 # length, then we should transfer the payload in one request.
81 82 data = initial_data + data.read()
82 83
83 84 if req_method in ['GET', 'PUT'] or transfer_encoding == 'chunked':
84 85 # NOTE(marcink): when getting/uploading files we want to STREAM content
85 86 # back to the client/proxy instead of buffering it here...
86 87 stream = True
87 88
88 89 stream = stream
89 90 log.debug(
90 91 'Calling SVN PROXY: method:%s via `%s`, Stream: %s',
91 92 req_method, path_info, stream)
92 93 response = requests.request(
93 94 req_method, path_info,
94 95 data=data, headers=request_headers, stream=stream)
95 96
96 97 if response.status_code not in [200, 401]:
97 98 if response.status_code >= 500:
98 99 log.error('Got SVN response:%s with text:\n`%s`',
99 100 response, response.text)
100 101 else:
101 102 log.debug('Got SVN response:%s with text:\n`%s`',
102 103 response, response.text)
103 104 else:
104 105 log.debug('got response code: %s', response.status_code)
105 106
106 107 response_headers = self._get_response_headers(response.headers)
107 108
108 109 if response.headers.get('SVN-Txn-name'):
109 110 svn_tx_id = response.headers.get('SVN-Txn-name')
110 111 txn_id = rc_cache.utils.compute_key_from_params(
111 112 self.config['repository'], svn_tx_id)
112 113 port = safe_int(self.rc_extras['hooks_uri'].split(':')[-1])
113 114 store_txn_id_data(txn_id, {'port': port})
114 115
115 116 start_response(
116 117 '{} {}'.format(response.status_code, response.reason),
117 118 response_headers)
118 119 return response.iter_content(chunk_size=1024)
119 120
120 def _get_url(self, path):
121 url_path = urlparse.urljoin(
122 self.config.get('subversion_http_server_url', ''), path)
121 def _get_url(self, svn_http_server, path):
122 svn_http_server_url = (svn_http_server or '').rstrip('/')
123 url_path = urlparse.urljoin(svn_http_server_url + '/', (path or '').lstrip('/'))
123 124 url_path = urllib.quote(url_path, safe="/:=~+!$,;'")
124 125 return url_path
125 126
126 127 def _get_request_headers(self, environ):
127 128 headers = {}
128 129
129 130 for key in environ:
130 131 if not key.startswith('HTTP_'):
131 132 continue
132 133 new_key = key.split('_')
133 134 new_key = [k.capitalize() for k in new_key[1:]]
134 135 new_key = '-'.join(new_key)
135 136 headers[new_key] = environ[key]
136 137
137 138 if 'CONTENT_TYPE' in environ:
138 139 headers['Content-Type'] = environ['CONTENT_TYPE']
139 140
140 141 if 'CONTENT_LENGTH' in environ:
141 142 headers['Content-Length'] = environ['CONTENT_LENGTH']
142 143
143 144 return headers
144 145
145 146 def _get_response_headers(self, headers):
146 147 headers = [
147 148 (h, headers[h])
148 149 for h in headers
149 150 if h.lower() not in self.IGNORED_HEADERS
150 151 ]
151 152
152 153 return headers
153 154
154 155
155 156 class DisabledSimpleSvnApp(object):
156 157 def __init__(self, config):
157 158 self.config = config
158 159
159 160 def __call__(self, environ, start_response):
160 161 reason = 'Cannot handle SVN call because: SVN HTTP Proxy is not enabled'
161 162 log.warning(reason)
162 163 return HTTPNotAcceptable(reason)(environ, start_response)
163 164
164 165
165 166 class SimpleSvn(simplevcs.SimpleVCS):
166 167
167 168 SCM = 'svn'
168 169 READ_ONLY_COMMANDS = ('OPTIONS', 'PROPFIND', 'GET', 'REPORT')
169 170 DEFAULT_HTTP_SERVER = 'http://localhost:8090'
170 171
171 172 def _get_repository_name(self, environ):
172 173 """
173 174 Gets repository name out of PATH_INFO header
174 175
175 176 :param environ: environ where PATH_INFO is stored
176 177 """
177 178 path = environ['PATH_INFO'].split('!')
178 179 repo_name = path[0].strip('/')
179 180
180 181 # SVN includes the whole path in it's requests, including
181 182 # subdirectories inside the repo. Therefore we have to search for
182 183 # the repo root directory.
183 184 if not is_valid_repo(
184 185 repo_name, self.base_path, explicit_scm=self.SCM):
185 186 current_path = ''
186 187 for component in repo_name.split('/'):
187 188 current_path += component
188 189 if is_valid_repo(
189 190 current_path, self.base_path, explicit_scm=self.SCM):
190 191 return current_path
191 192 current_path += '/'
192 193
193 194 return repo_name
194 195
195 196 def _get_action(self, environ):
196 197 return (
197 198 'pull'
198 199 if environ['REQUEST_METHOD'] in self.READ_ONLY_COMMANDS
199 200 else 'push')
200 201
201 202 def _should_use_callback_daemon(self, extras, environ, action):
202 203 # only MERGE command triggers hooks, so we don't want to start
203 204 # hooks server too many times. POST however starts the svn transaction
204 205 # so we also need to run the init of callback daemon of POST
205 206 if environ['REQUEST_METHOD'] in ['MERGE', 'POST']:
206 207 return True
207 208 return False
208 209
209 210 def _create_wsgi_app(self, repo_path, repo_name, config):
210 211 if self._is_svn_enabled():
211 212 return SimpleSvnApp(config)
212 213 # we don't have http proxy enabled return dummy request handler
213 214 return DisabledSimpleSvnApp(config)
214 215
215 216 def _is_svn_enabled(self):
216 217 conf = self.repo_vcs_config
217 218 return str2bool(conf.get('vcs_svn_proxy', 'http_requests_enabled'))
218 219
219 220 def _create_config(self, extras, repo_name):
220 221 conf = self.repo_vcs_config
221 222 server_url = conf.get('vcs_svn_proxy', 'http_server_url')
222 223 server_url = server_url or self.DEFAULT_HTTP_SERVER
223 224
224 225 extras['subversion_http_server_url'] = server_url
225 226 return extras
@@ -1,1022 +1,1022 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
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 20
21 21
22 22 """
23 23 Some simple helper functions
24 24 """
25 25
26 26 import collections
27 27 import datetime
28 28 import dateutil.relativedelta
29 29 import hashlib
30 30 import logging
31 31 import re
32 32 import sys
33 33 import time
34 34 import urllib
35 35 import urlobject
36 36 import uuid
37 37 import getpass
38 38
39 39 import pygments.lexers
40 40 import sqlalchemy
41 41 import sqlalchemy.engine.url
42 42 import sqlalchemy.exc
43 43 import sqlalchemy.sql
44 44 import webob
45 45 import pyramid.threadlocal
46 from pyramid.settings import asbool
46 47
47 48 import rhodecode
48 49 from rhodecode.translation import _, _pluralize
49 50
50 51
51 52 def md5(s):
52 53 return hashlib.md5(s).hexdigest()
53 54
54 55
55 56 def md5_safe(s):
56 57 return md5(safe_str(s))
57 58
58 59
59 60 def sha1(s):
60 61 return hashlib.sha1(s).hexdigest()
61 62
62 63
63 64 def sha1_safe(s):
64 65 return sha1(safe_str(s))
65 66
66 67
67 68 def __get_lem(extra_mapping=None):
68 69 """
69 70 Get language extension map based on what's inside pygments lexers
70 71 """
71 72 d = collections.defaultdict(lambda: [])
72 73
73 74 def __clean(s):
74 75 s = s.lstrip('*')
75 76 s = s.lstrip('.')
76 77
77 78 if s.find('[') != -1:
78 79 exts = []
79 80 start, stop = s.find('['), s.find(']')
80 81
81 82 for suffix in s[start + 1:stop]:
82 83 exts.append(s[:s.find('[')] + suffix)
83 84 return [e.lower() for e in exts]
84 85 else:
85 86 return [s.lower()]
86 87
87 88 for lx, t in sorted(pygments.lexers.LEXERS.items()):
88 89 m = map(__clean, t[-2])
89 90 if m:
90 91 m = reduce(lambda x, y: x + y, m)
91 92 for ext in m:
92 93 desc = lx.replace('Lexer', '')
93 94 d[ext].append(desc)
94 95
95 96 data = dict(d)
96 97
97 98 extra_mapping = extra_mapping or {}
98 99 if extra_mapping:
99 100 for k, v in extra_mapping.items():
100 101 if k not in data:
101 102 # register new mapping2lexer
102 103 data[k] = [v]
103 104
104 105 return data
105 106
106 107
107 108 def str2bool(_str):
108 109 """
109 110 returns True/False value from given string, it tries to translate the
110 111 string into boolean
111 112
112 113 :param _str: string value to translate into boolean
113 114 :rtype: boolean
114 115 :returns: boolean from given string
115 116 """
116 117 if _str is None:
117 118 return False
118 119 if _str in (True, False):
119 120 return _str
120 121 _str = str(_str).strip().lower()
121 122 return _str in ('t', 'true', 'y', 'yes', 'on', '1')
122 123
123 124
124 125 def aslist(obj, sep=None, strip=True):
125 126 """
126 127 Returns given string separated by sep as list
127 128
128 129 :param obj:
129 130 :param sep:
130 131 :param strip:
131 132 """
132 133 if isinstance(obj, (basestring,)):
133 134 lst = obj.split(sep)
134 135 if strip:
135 136 lst = [v.strip() for v in lst]
136 137 return lst
137 138 elif isinstance(obj, (list, tuple)):
138 139 return obj
139 140 elif obj is None:
140 141 return []
141 142 else:
142 143 return [obj]
143 144
144 145
145 146 def convert_line_endings(line, mode):
146 147 """
147 148 Converts a given line "line end" accordingly to given mode
148 149
149 150 Available modes are::
150 151 0 - Unix
151 152 1 - Mac
152 153 2 - DOS
153 154
154 155 :param line: given line to convert
155 156 :param mode: mode to convert to
156 157 :rtype: str
157 158 :return: converted line according to mode
158 159 """
159 160 if mode == 0:
160 161 line = line.replace('\r\n', '\n')
161 162 line = line.replace('\r', '\n')
162 163 elif mode == 1:
163 164 line = line.replace('\r\n', '\r')
164 165 line = line.replace('\n', '\r')
165 166 elif mode == 2:
166 167 line = re.sub('\r(?!\n)|(?<!\r)\n', '\r\n', line)
167 168 return line
168 169
169 170
170 171 def detect_mode(line, default):
171 172 """
172 173 Detects line break for given line, if line break couldn't be found
173 174 given default value is returned
174 175
175 176 :param line: str line
176 177 :param default: default
177 178 :rtype: int
178 179 :return: value of line end on of 0 - Unix, 1 - Mac, 2 - DOS
179 180 """
180 181 if line.endswith('\r\n'):
181 182 return 2
182 183 elif line.endswith('\n'):
183 184 return 0
184 185 elif line.endswith('\r'):
185 186 return 1
186 187 else:
187 188 return default
188 189
189 190
190 191 def safe_int(val, default=None):
191 192 """
192 193 Returns int() of val if val is not convertable to int use default
193 194 instead
194 195
195 196 :param val:
196 197 :param default:
197 198 """
198 199
199 200 try:
200 201 val = int(val)
201 202 except (ValueError, TypeError):
202 203 val = default
203 204
204 205 return val
205 206
206 207
207 208 def safe_unicode(str_, from_encoding=None):
208 209 """
209 210 safe unicode function. Does few trick to turn str_ into unicode
210 211
211 212 In case of UnicodeDecode error, we try to return it with encoding detected
212 213 by chardet library if it fails fallback to unicode with errors replaced
213 214
214 215 :param str_: string to decode
215 216 :rtype: unicode
216 217 :returns: unicode object
217 218 """
218 219 if isinstance(str_, unicode):
219 220 return str_
220 221
221 222 if not from_encoding:
222 223 DEFAULT_ENCODINGS = aslist(rhodecode.CONFIG.get('default_encoding',
223 224 'utf8'), sep=',')
224 225 from_encoding = DEFAULT_ENCODINGS
225 226
226 227 if not isinstance(from_encoding, (list, tuple)):
227 228 from_encoding = [from_encoding]
228 229
229 230 try:
230 231 return unicode(str_)
231 232 except UnicodeDecodeError:
232 233 pass
233 234
234 235 for enc in from_encoding:
235 236 try:
236 237 return unicode(str_, enc)
237 238 except UnicodeDecodeError:
238 239 pass
239 240
240 241 try:
241 242 import chardet
242 243 encoding = chardet.detect(str_)['encoding']
243 244 if encoding is None:
244 245 raise Exception()
245 246 return str_.decode(encoding)
246 247 except (ImportError, UnicodeDecodeError, Exception):
247 248 return unicode(str_, from_encoding[0], 'replace')
248 249
249 250
250 251 def safe_str(unicode_, to_encoding=None):
251 252 """
252 253 safe str function. Does few trick to turn unicode_ into string
253 254
254 255 In case of UnicodeEncodeError, we try to return it with encoding detected
255 256 by chardet library if it fails fallback to string with errors replaced
256 257
257 258 :param unicode_: unicode to encode
258 259 :rtype: str
259 260 :returns: str object
260 261 """
261 262
262 263 # if it's not basestr cast to str
263 264 if not isinstance(unicode_, basestring):
264 265 return str(unicode_)
265 266
266 267 if isinstance(unicode_, str):
267 268 return unicode_
268 269
269 270 if not to_encoding:
270 271 DEFAULT_ENCODINGS = aslist(rhodecode.CONFIG.get('default_encoding',
271 272 'utf8'), sep=',')
272 273 to_encoding = DEFAULT_ENCODINGS
273 274
274 275 if not isinstance(to_encoding, (list, tuple)):
275 276 to_encoding = [to_encoding]
276 277
277 278 for enc in to_encoding:
278 279 try:
279 280 return unicode_.encode(enc)
280 281 except UnicodeEncodeError:
281 282 pass
282 283
283 284 try:
284 285 import chardet
285 286 encoding = chardet.detect(unicode_)['encoding']
286 287 if encoding is None:
287 288 raise UnicodeEncodeError()
288 289
289 290 return unicode_.encode(encoding)
290 291 except (ImportError, UnicodeEncodeError):
291 292 return unicode_.encode(to_encoding[0], 'replace')
292 293
293 294
294 295 def remove_suffix(s, suffix):
295 296 if s.endswith(suffix):
296 297 s = s[:-1 * len(suffix)]
297 298 return s
298 299
299 300
300 301 def remove_prefix(s, prefix):
301 302 if s.startswith(prefix):
302 303 s = s[len(prefix):]
303 304 return s
304 305
305 306
306 307 def find_calling_context(ignore_modules=None):
307 308 """
308 309 Look through the calling stack and return the frame which called
309 310 this function and is part of core module ( ie. rhodecode.* )
310 311
311 312 :param ignore_modules: list of modules to ignore eg. ['rhodecode.lib']
312 313 """
313 314
314 315 ignore_modules = ignore_modules or []
315 316
316 317 f = sys._getframe(2)
317 318 while f.f_back is not None:
318 319 name = f.f_globals.get('__name__')
319 320 if name and name.startswith(__name__.split('.')[0]):
320 321 if name not in ignore_modules:
321 322 return f
322 323 f = f.f_back
323 324 return None
324 325
325 326
326 327 def ping_connection(connection, branch):
327 328 if branch:
328 329 # "branch" refers to a sub-connection of a connection,
329 330 # we don't want to bother pinging on these.
330 331 return
331 332
332 333 # turn off "close with result". This flag is only used with
333 334 # "connectionless" execution, otherwise will be False in any case
334 335 save_should_close_with_result = connection.should_close_with_result
335 336 connection.should_close_with_result = False
336 337
337 338 try:
338 339 # run a SELECT 1. use a core select() so that
339 340 # the SELECT of a scalar value without a table is
340 341 # appropriately formatted for the backend
341 342 connection.scalar(sqlalchemy.sql.select([1]))
342 343 except sqlalchemy.exc.DBAPIError as err:
343 344 # catch SQLAlchemy's DBAPIError, which is a wrapper
344 345 # for the DBAPI's exception. It includes a .connection_invalidated
345 346 # attribute which specifies if this connection is a "disconnect"
346 347 # condition, which is based on inspection of the original exception
347 348 # by the dialect in use.
348 349 if err.connection_invalidated:
349 350 # run the same SELECT again - the connection will re-validate
350 351 # itself and establish a new connection. The disconnect detection
351 352 # here also causes the whole connection pool to be invalidated
352 353 # so that all stale connections are discarded.
353 354 connection.scalar(sqlalchemy.sql.select([1]))
354 355 else:
355 356 raise
356 357 finally:
357 358 # restore "close with result"
358 359 connection.should_close_with_result = save_should_close_with_result
359 360
360 361
361 362 def engine_from_config(configuration, prefix='sqlalchemy.', **kwargs):
362 363 """Custom engine_from_config functions."""
363 364 log = logging.getLogger('sqlalchemy.engine')
364 _ping_connection = configuration.pop('sqlalchemy.db1.ping_connection', None)
365 use_ping_connection = asbool(configuration.pop('sqlalchemy.db1.ping_connection', None))
366 debug = asbool(configuration.get('debug'))
365 367
366 368 engine = sqlalchemy.engine_from_config(configuration, prefix, **kwargs)
367 369
368 370 def color_sql(sql):
369 371 color_seq = '\033[1;33m' # This is yellow: code 33
370 372 normal = '\x1b[0m'
371 373 return ''.join([color_seq, sql, normal])
372 374
373 if configuration['debug'] or _ping_connection:
375 if use_ping_connection:
376 log.debug('Adding ping_connection on the engine config.')
374 377 sqlalchemy.event.listen(engine, "engine_connect", ping_connection)
375 378
376 if configuration['debug']:
379 if debug:
377 380 # attach events only for debug configuration
378
379 381 def before_cursor_execute(conn, cursor, statement,
380 382 parameters, context, executemany):
381 383 setattr(conn, 'query_start_time', time.time())
382 384 log.info(color_sql(">>>>> STARTING QUERY >>>>>"))
383 385 calling_context = find_calling_context(ignore_modules=[
384 386 'rhodecode.lib.caching_query',
385 387 'rhodecode.model.settings',
386 388 ])
387 389 if calling_context:
388 390 log.info(color_sql('call context %s:%s' % (
389 391 calling_context.f_code.co_filename,
390 392 calling_context.f_lineno,
391 393 )))
392 394
393 395 def after_cursor_execute(conn, cursor, statement,
394 396 parameters, context, executemany):
395 397 delattr(conn, 'query_start_time')
396 398
397 sqlalchemy.event.listen(engine, "before_cursor_execute",
398 before_cursor_execute)
399 sqlalchemy.event.listen(engine, "after_cursor_execute",
400 after_cursor_execute)
399 sqlalchemy.event.listen(engine, "before_cursor_execute", before_cursor_execute)
400 sqlalchemy.event.listen(engine, "after_cursor_execute", after_cursor_execute)
401 401
402 402 return engine
403 403
404 404
405 405 def get_encryption_key(config):
406 406 secret = config.get('rhodecode.encrypted_values.secret')
407 407 default = config['beaker.session.secret']
408 408 return secret or default
409 409
410 410
411 411 def age(prevdate, now=None, show_short_version=False, show_suffix=True,
412 412 short_format=False):
413 413 """
414 414 Turns a datetime into an age string.
415 415 If show_short_version is True, this generates a shorter string with
416 416 an approximate age; ex. '1 day ago', rather than '1 day and 23 hours ago'.
417 417
418 418 * IMPORTANT*
419 419 Code of this function is written in special way so it's easier to
420 420 backport it to javascript. If you mean to update it, please also update
421 421 `jquery.timeago-extension.js` file
422 422
423 423 :param prevdate: datetime object
424 424 :param now: get current time, if not define we use
425 425 `datetime.datetime.now()`
426 426 :param show_short_version: if it should approximate the date and
427 427 return a shorter string
428 428 :param show_suffix:
429 429 :param short_format: show short format, eg 2D instead of 2 days
430 430 :rtype: unicode
431 431 :returns: unicode words describing age
432 432 """
433 433
434 434 def _get_relative_delta(now, prevdate):
435 435 base = dateutil.relativedelta.relativedelta(now, prevdate)
436 436 return {
437 437 'year': base.years,
438 438 'month': base.months,
439 439 'day': base.days,
440 440 'hour': base.hours,
441 441 'minute': base.minutes,
442 442 'second': base.seconds,
443 443 }
444 444
445 445 def _is_leap_year(year):
446 446 return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
447 447
448 448 def get_month(prevdate):
449 449 return prevdate.month
450 450
451 451 def get_year(prevdate):
452 452 return prevdate.year
453 453
454 454 now = now or datetime.datetime.now()
455 455 order = ['year', 'month', 'day', 'hour', 'minute', 'second']
456 456 deltas = {}
457 457 future = False
458 458
459 459 if prevdate > now:
460 460 now_old = now
461 461 now = prevdate
462 462 prevdate = now_old
463 463 future = True
464 464 if future:
465 465 prevdate = prevdate.replace(microsecond=0)
466 466 # Get date parts deltas
467 467 for part in order:
468 468 rel_delta = _get_relative_delta(now, prevdate)
469 469 deltas[part] = rel_delta[part]
470 470
471 471 # Fix negative offsets (there is 1 second between 10:59:59 and 11:00:00,
472 472 # not 1 hour, -59 minutes and -59 seconds)
473 473 offsets = [[5, 60], [4, 60], [3, 24]]
474 474 for element in offsets: # seconds, minutes, hours
475 475 num = element[0]
476 476 length = element[1]
477 477
478 478 part = order[num]
479 479 carry_part = order[num - 1]
480 480
481 481 if deltas[part] < 0:
482 482 deltas[part] += length
483 483 deltas[carry_part] -= 1
484 484
485 485 # Same thing for days except that the increment depends on the (variable)
486 486 # number of days in the month
487 487 month_lengths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
488 488 if deltas['day'] < 0:
489 489 if get_month(prevdate) == 2 and _is_leap_year(get_year(prevdate)):
490 490 deltas['day'] += 29
491 491 else:
492 492 deltas['day'] += month_lengths[get_month(prevdate) - 1]
493 493
494 494 deltas['month'] -= 1
495 495
496 496 if deltas['month'] < 0:
497 497 deltas['month'] += 12
498 498 deltas['year'] -= 1
499 499
500 500 # Format the result
501 501 if short_format:
502 502 fmt_funcs = {
503 503 'year': lambda d: u'%dy' % d,
504 504 'month': lambda d: u'%dm' % d,
505 505 'day': lambda d: u'%dd' % d,
506 506 'hour': lambda d: u'%dh' % d,
507 507 'minute': lambda d: u'%dmin' % d,
508 508 'second': lambda d: u'%dsec' % d,
509 509 }
510 510 else:
511 511 fmt_funcs = {
512 512 'year': lambda d: _pluralize(u'${num} year', u'${num} years', d, mapping={'num': d}).interpolate(),
513 513 'month': lambda d: _pluralize(u'${num} month', u'${num} months', d, mapping={'num': d}).interpolate(),
514 514 'day': lambda d: _pluralize(u'${num} day', u'${num} days', d, mapping={'num': d}).interpolate(),
515 515 'hour': lambda d: _pluralize(u'${num} hour', u'${num} hours', d, mapping={'num': d}).interpolate(),
516 516 'minute': lambda d: _pluralize(u'${num} minute', u'${num} minutes', d, mapping={'num': d}).interpolate(),
517 517 'second': lambda d: _pluralize(u'${num} second', u'${num} seconds', d, mapping={'num': d}).interpolate(),
518 518 }
519 519
520 520 i = 0
521 521 for part in order:
522 522 value = deltas[part]
523 523 if value != 0:
524 524
525 525 if i < 5:
526 526 sub_part = order[i + 1]
527 527 sub_value = deltas[sub_part]
528 528 else:
529 529 sub_value = 0
530 530
531 531 if sub_value == 0 or show_short_version:
532 532 _val = fmt_funcs[part](value)
533 533 if future:
534 534 if show_suffix:
535 535 return _(u'in ${ago}', mapping={'ago': _val})
536 536 else:
537 537 return _(_val)
538 538
539 539 else:
540 540 if show_suffix:
541 541 return _(u'${ago} ago', mapping={'ago': _val})
542 542 else:
543 543 return _(_val)
544 544
545 545 val = fmt_funcs[part](value)
546 546 val_detail = fmt_funcs[sub_part](sub_value)
547 547 mapping = {'val': val, 'detail': val_detail}
548 548
549 549 if short_format:
550 550 datetime_tmpl = _(u'${val}, ${detail}', mapping=mapping)
551 551 if show_suffix:
552 552 datetime_tmpl = _(u'${val}, ${detail} ago', mapping=mapping)
553 553 if future:
554 554 datetime_tmpl = _(u'in ${val}, ${detail}', mapping=mapping)
555 555 else:
556 556 datetime_tmpl = _(u'${val} and ${detail}', mapping=mapping)
557 557 if show_suffix:
558 558 datetime_tmpl = _(u'${val} and ${detail} ago', mapping=mapping)
559 559 if future:
560 560 datetime_tmpl = _(u'in ${val} and ${detail}', mapping=mapping)
561 561
562 562 return datetime_tmpl
563 563 i += 1
564 564 return _(u'just now')
565 565
566 566
567 567 def cleaned_uri(uri):
568 568 """
569 569 Quotes '[' and ']' from uri if there is only one of them.
570 570 according to RFC3986 we cannot use such chars in uri
571 571 :param uri:
572 572 :return: uri without this chars
573 573 """
574 574 return urllib.quote(uri, safe='@$:/')
575 575
576 576
577 577 def uri_filter(uri):
578 578 """
579 579 Removes user:password from given url string
580 580
581 581 :param uri:
582 582 :rtype: unicode
583 583 :returns: filtered list of strings
584 584 """
585 585 if not uri:
586 586 return ''
587 587
588 588 proto = ''
589 589
590 590 for pat in ('https://', 'http://'):
591 591 if uri.startswith(pat):
592 592 uri = uri[len(pat):]
593 593 proto = pat
594 594 break
595 595
596 596 # remove passwords and username
597 597 uri = uri[uri.find('@') + 1:]
598 598
599 599 # get the port
600 600 cred_pos = uri.find(':')
601 601 if cred_pos == -1:
602 602 host, port = uri, None
603 603 else:
604 604 host, port = uri[:cred_pos], uri[cred_pos + 1:]
605 605
606 606 return filter(None, [proto, host, port])
607 607
608 608
609 609 def credentials_filter(uri):
610 610 """
611 611 Returns a url with removed credentials
612 612
613 613 :param uri:
614 614 """
615 615
616 616 uri = uri_filter(uri)
617 617 # check if we have port
618 618 if len(uri) > 2 and uri[2]:
619 619 uri[2] = ':' + uri[2]
620 620
621 621 return ''.join(uri)
622 622
623 623
624 624 def get_clone_url(request, uri_tmpl, repo_name, repo_id, **override):
625 625 qualifed_home_url = request.route_url('home')
626 626 parsed_url = urlobject.URLObject(qualifed_home_url)
627 627 decoded_path = safe_unicode(urllib.unquote(parsed_url.path.rstrip('/')))
628 628
629 629 args = {
630 630 'scheme': parsed_url.scheme,
631 631 'user': '',
632 632 'sys_user': getpass.getuser(),
633 633 # path if we use proxy-prefix
634 634 'netloc': parsed_url.netloc+decoded_path,
635 635 'hostname': parsed_url.hostname,
636 636 'prefix': decoded_path,
637 637 'repo': repo_name,
638 638 'repoid': str(repo_id)
639 639 }
640 640 args.update(override)
641 641 args['user'] = urllib.quote(safe_str(args['user']))
642 642
643 643 for k, v in args.items():
644 644 uri_tmpl = uri_tmpl.replace('{%s}' % k, v)
645 645
646 646 # remove leading @ sign if it's present. Case of empty user
647 647 url_obj = urlobject.URLObject(uri_tmpl)
648 648 url = url_obj.with_netloc(url_obj.netloc.lstrip('@'))
649 649
650 650 return safe_unicode(url)
651 651
652 652
653 653 def get_commit_safe(repo, commit_id=None, commit_idx=None, pre_load=None):
654 654 """
655 655 Safe version of get_commit if this commit doesn't exists for a
656 656 repository it returns a Dummy one instead
657 657
658 658 :param repo: repository instance
659 659 :param commit_id: commit id as str
660 660 :param pre_load: optional list of commit attributes to load
661 661 """
662 662 # TODO(skreft): remove these circular imports
663 663 from rhodecode.lib.vcs.backends.base import BaseRepository, EmptyCommit
664 664 from rhodecode.lib.vcs.exceptions import RepositoryError
665 665 if not isinstance(repo, BaseRepository):
666 666 raise Exception('You must pass an Repository '
667 667 'object as first argument got %s', type(repo))
668 668
669 669 try:
670 670 commit = repo.get_commit(
671 671 commit_id=commit_id, commit_idx=commit_idx, pre_load=pre_load)
672 672 except (RepositoryError, LookupError):
673 673 commit = EmptyCommit()
674 674 return commit
675 675
676 676
677 677 def datetime_to_time(dt):
678 678 if dt:
679 679 return time.mktime(dt.timetuple())
680 680
681 681
682 682 def time_to_datetime(tm):
683 683 if tm:
684 684 if isinstance(tm, basestring):
685 685 try:
686 686 tm = float(tm)
687 687 except ValueError:
688 688 return
689 689 return datetime.datetime.fromtimestamp(tm)
690 690
691 691
692 692 def time_to_utcdatetime(tm):
693 693 if tm:
694 694 if isinstance(tm, basestring):
695 695 try:
696 696 tm = float(tm)
697 697 except ValueError:
698 698 return
699 699 return datetime.datetime.utcfromtimestamp(tm)
700 700
701 701
702 702 MENTIONS_REGEX = re.compile(
703 703 # ^@ or @ without any special chars in front
704 704 r'(?:^@|[^a-zA-Z0-9\-\_\.]@)'
705 705 # main body starts with letter, then can be . - _
706 706 r'([a-zA-Z0-9]{1}[a-zA-Z0-9\-\_\.]+)',
707 707 re.VERBOSE | re.MULTILINE)
708 708
709 709
710 710 def extract_mentioned_users(s):
711 711 """
712 712 Returns unique usernames from given string s that have @mention
713 713
714 714 :param s: string to get mentions
715 715 """
716 716 usrs = set()
717 717 for username in MENTIONS_REGEX.findall(s):
718 718 usrs.add(username)
719 719
720 720 return sorted(list(usrs), key=lambda k: k.lower())
721 721
722 722
723 723 class AttributeDictBase(dict):
724 724 def __getstate__(self):
725 725 odict = self.__dict__ # get attribute dictionary
726 726 return odict
727 727
728 728 def __setstate__(self, dict):
729 729 self.__dict__ = dict
730 730
731 731 __setattr__ = dict.__setitem__
732 732 __delattr__ = dict.__delitem__
733 733
734 734
735 735 class StrictAttributeDict(AttributeDictBase):
736 736 """
737 737 Strict Version of Attribute dict which raises an Attribute error when
738 738 requested attribute is not set
739 739 """
740 740 def __getattr__(self, attr):
741 741 try:
742 742 return self[attr]
743 743 except KeyError:
744 744 raise AttributeError('%s object has no attribute %s' % (
745 745 self.__class__, attr))
746 746
747 747
748 748 class AttributeDict(AttributeDictBase):
749 749 def __getattr__(self, attr):
750 750 return self.get(attr, None)
751 751
752 752
753 753
754 754 class OrderedDefaultDict(collections.OrderedDict, collections.defaultdict):
755 755 def __init__(self, default_factory=None, *args, **kwargs):
756 756 # in python3 you can omit the args to super
757 757 super(OrderedDefaultDict, self).__init__(*args, **kwargs)
758 758 self.default_factory = default_factory
759 759
760 760
761 761 def fix_PATH(os_=None):
762 762 """
763 763 Get current active python path, and append it to PATH variable to fix
764 764 issues of subprocess calls and different python versions
765 765 """
766 766 if os_ is None:
767 767 import os
768 768 else:
769 769 os = os_
770 770
771 771 cur_path = os.path.split(sys.executable)[0]
772 772 if not os.environ['PATH'].startswith(cur_path):
773 773 os.environ['PATH'] = '%s:%s' % (cur_path, os.environ['PATH'])
774 774
775 775
776 776 def obfuscate_url_pw(engine):
777 777 _url = engine or ''
778 778 try:
779 779 _url = sqlalchemy.engine.url.make_url(engine)
780 780 if _url.password:
781 781 _url.password = 'XXXXX'
782 782 except Exception:
783 783 pass
784 784 return unicode(_url)
785 785
786 786
787 787 def get_server_url(environ):
788 788 req = webob.Request(environ)
789 789 return req.host_url + req.script_name
790 790
791 791
792 792 def unique_id(hexlen=32):
793 793 alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjklmnpqrstuvwxyz"
794 794 return suuid(truncate_to=hexlen, alphabet=alphabet)
795 795
796 796
797 797 def suuid(url=None, truncate_to=22, alphabet=None):
798 798 """
799 799 Generate and return a short URL safe UUID.
800 800
801 801 If the url parameter is provided, set the namespace to the provided
802 802 URL and generate a UUID.
803 803
804 804 :param url to get the uuid for
805 805 :truncate_to: truncate the basic 22 UUID to shorter version
806 806
807 807 The IDs won't be universally unique any longer, but the probability of
808 808 a collision will still be very low.
809 809 """
810 810 # Define our alphabet.
811 811 _ALPHABET = alphabet or "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
812 812
813 813 # If no URL is given, generate a random UUID.
814 814 if url is None:
815 815 unique_id = uuid.uuid4().int
816 816 else:
817 817 unique_id = uuid.uuid3(uuid.NAMESPACE_URL, url).int
818 818
819 819 alphabet_length = len(_ALPHABET)
820 820 output = []
821 821 while unique_id > 0:
822 822 digit = unique_id % alphabet_length
823 823 output.append(_ALPHABET[digit])
824 824 unique_id = int(unique_id / alphabet_length)
825 825 return "".join(output)[:truncate_to]
826 826
827 827
828 828 def get_current_rhodecode_user(request=None):
829 829 """
830 830 Gets rhodecode user from request
831 831 """
832 832 pyramid_request = request or pyramid.threadlocal.get_current_request()
833 833
834 834 # web case
835 835 if pyramid_request and hasattr(pyramid_request, 'user'):
836 836 return pyramid_request.user
837 837
838 838 # api case
839 839 if pyramid_request and hasattr(pyramid_request, 'rpc_user'):
840 840 return pyramid_request.rpc_user
841 841
842 842 return None
843 843
844 844
845 845 def action_logger_generic(action, namespace=''):
846 846 """
847 847 A generic logger for actions useful to the system overview, tries to find
848 848 an acting user for the context of the call otherwise reports unknown user
849 849
850 850 :param action: logging message eg 'comment 5 deleted'
851 851 :param type: string
852 852
853 853 :param namespace: namespace of the logging message eg. 'repo.comments'
854 854 :param type: string
855 855
856 856 """
857 857
858 858 logger_name = 'rhodecode.actions'
859 859
860 860 if namespace:
861 861 logger_name += '.' + namespace
862 862
863 863 log = logging.getLogger(logger_name)
864 864
865 865 # get a user if we can
866 866 user = get_current_rhodecode_user()
867 867
868 868 logfunc = log.info
869 869
870 870 if not user:
871 871 user = '<unknown user>'
872 872 logfunc = log.warning
873 873
874 874 logfunc('Logging action by {}: {}'.format(user, action))
875 875
876 876
877 877 def escape_split(text, sep=',', maxsplit=-1):
878 878 r"""
879 879 Allows for escaping of the separator: e.g. arg='foo\, bar'
880 880
881 881 It should be noted that the way bash et. al. do command line parsing, those
882 882 single quotes are required.
883 883 """
884 884 escaped_sep = r'\%s' % sep
885 885
886 886 if escaped_sep not in text:
887 887 return text.split(sep, maxsplit)
888 888
889 889 before, _mid, after = text.partition(escaped_sep)
890 890 startlist = before.split(sep, maxsplit) # a regular split is fine here
891 891 unfinished = startlist[-1]
892 892 startlist = startlist[:-1]
893 893
894 894 # recurse because there may be more escaped separators
895 895 endlist = escape_split(after, sep, maxsplit)
896 896
897 897 # finish building the escaped value. we use endlist[0] becaue the first
898 898 # part of the string sent in recursion is the rest of the escaped value.
899 899 unfinished += sep + endlist[0]
900 900
901 901 return startlist + [unfinished] + endlist[1:] # put together all the parts
902 902
903 903
904 904 class OptionalAttr(object):
905 905 """
906 906 Special Optional Option that defines other attribute. Example::
907 907
908 908 def test(apiuser, userid=Optional(OAttr('apiuser')):
909 909 user = Optional.extract(userid)
910 910 # calls
911 911
912 912 """
913 913
914 914 def __init__(self, attr_name):
915 915 self.attr_name = attr_name
916 916
917 917 def __repr__(self):
918 918 return '<OptionalAttr:%s>' % self.attr_name
919 919
920 920 def __call__(self):
921 921 return self
922 922
923 923
924 924 # alias
925 925 OAttr = OptionalAttr
926 926
927 927
928 928 class Optional(object):
929 929 """
930 930 Defines an optional parameter::
931 931
932 932 param = param.getval() if isinstance(param, Optional) else param
933 933 param = param() if isinstance(param, Optional) else param
934 934
935 935 is equivalent of::
936 936
937 937 param = Optional.extract(param)
938 938
939 939 """
940 940
941 941 def __init__(self, type_):
942 942 self.type_ = type_
943 943
944 944 def __repr__(self):
945 945 return '<Optional:%s>' % self.type_.__repr__()
946 946
947 947 def __call__(self):
948 948 return self.getval()
949 949
950 950 def getval(self):
951 951 """
952 952 returns value from this Optional instance
953 953 """
954 954 if isinstance(self.type_, OAttr):
955 955 # use params name
956 956 return self.type_.attr_name
957 957 return self.type_
958 958
959 959 @classmethod
960 960 def extract(cls, val):
961 961 """
962 962 Extracts value from Optional() instance
963 963
964 964 :param val:
965 965 :return: original value if it's not Optional instance else
966 966 value of instance
967 967 """
968 968 if isinstance(val, cls):
969 969 return val.getval()
970 970 return val
971 971
972 972
973 973 def glob2re(pat):
974 974 """
975 975 Translate a shell PATTERN to a regular expression.
976 976
977 977 There is no way to quote meta-characters.
978 978 """
979 979
980 980 i, n = 0, len(pat)
981 981 res = ''
982 982 while i < n:
983 983 c = pat[i]
984 984 i = i+1
985 985 if c == '*':
986 986 #res = res + '.*'
987 987 res = res + '[^/]*'
988 988 elif c == '?':
989 989 #res = res + '.'
990 990 res = res + '[^/]'
991 991 elif c == '[':
992 992 j = i
993 993 if j < n and pat[j] == '!':
994 994 j = j+1
995 995 if j < n and pat[j] == ']':
996 996 j = j+1
997 997 while j < n and pat[j] != ']':
998 998 j = j+1
999 999 if j >= n:
1000 1000 res = res + '\\['
1001 1001 else:
1002 1002 stuff = pat[i:j].replace('\\','\\\\')
1003 1003 i = j+1
1004 1004 if stuff[0] == '!':
1005 1005 stuff = '^' + stuff[1:]
1006 1006 elif stuff[0] == '^':
1007 1007 stuff = '\\' + stuff
1008 1008 res = '%s[%s]' % (res, stuff)
1009 1009 else:
1010 1010 res = res + re.escape(c)
1011 1011 return res + '\Z(?ms)'
1012 1012
1013 1013
1014 1014 def parse_byte_string(size_str):
1015 1015 match = re.match(r'(\d+)(MB|KB)', size_str, re.IGNORECASE)
1016 1016 if not match:
1017 1017 raise ValueError('Given size:%s is invalid, please make sure '
1018 1018 'to use format of <num>(MB|KB)' % size_str)
1019 1019
1020 1020 _parts = match.groups()
1021 1021 num, type_ = _parts
1022 1022 return long(num) * {'mb': 1024*1024, 'kb': 1024}[type_.lower()]
@@ -1,1694 +1,1696 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
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 20
21 21
22 22 """
23 23 pull request model for RhodeCode
24 24 """
25 25
26 26
27 27 import json
28 28 import logging
29 29 import datetime
30 30 import urllib
31 31 import collections
32 32
33 33 from pyramid.threadlocal import get_current_request
34 34
35 35 from rhodecode import events
36 36 from rhodecode.translation import lazy_ugettext
37 37 from rhodecode.lib import helpers as h, hooks_utils, diffs
38 38 from rhodecode.lib import audit_logger
39 39 from rhodecode.lib.compat import OrderedDict
40 40 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
41 41 from rhodecode.lib.markup_renderer import (
42 42 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
43 43 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
44 44 from rhodecode.lib.vcs.backends.base import (
45 45 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
46 46 from rhodecode.lib.vcs.conf import settings as vcs_settings
47 47 from rhodecode.lib.vcs.exceptions import (
48 48 CommitDoesNotExistError, EmptyRepositoryError)
49 49 from rhodecode.model import BaseModel
50 50 from rhodecode.model.changeset_status import ChangesetStatusModel
51 51 from rhodecode.model.comment import CommentsModel
52 52 from rhodecode.model.db import (
53 53 or_, PullRequest, PullRequestReviewers, ChangesetStatus,
54 54 PullRequestVersion, ChangesetComment, Repository, RepoReviewRule)
55 55 from rhodecode.model.meta import Session
56 56 from rhodecode.model.notification import NotificationModel, \
57 57 EmailNotificationModel
58 58 from rhodecode.model.scm import ScmModel
59 59 from rhodecode.model.settings import VcsSettingsModel
60 60
61 61
62 62 log = logging.getLogger(__name__)
63 63
64 64
65 65 # Data structure to hold the response data when updating commits during a pull
66 66 # request update.
67 67 UpdateResponse = collections.namedtuple('UpdateResponse', [
68 68 'executed', 'reason', 'new', 'old', 'changes',
69 69 'source_changed', 'target_changed'])
70 70
71 71
72 72 class PullRequestModel(BaseModel):
73 73
74 74 cls = PullRequest
75 75
76 76 DIFF_CONTEXT = diffs.DEFAULT_CONTEXT
77 77
78 78 UPDATE_STATUS_MESSAGES = {
79 79 UpdateFailureReason.NONE: lazy_ugettext(
80 80 'Pull request update successful.'),
81 81 UpdateFailureReason.UNKNOWN: lazy_ugettext(
82 82 'Pull request update failed because of an unknown error.'),
83 83 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
84 84 'No update needed because the source and target have not changed.'),
85 85 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
86 86 'Pull request cannot be updated because the reference type is '
87 87 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
88 88 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
89 89 'This pull request cannot be updated because the target '
90 90 'reference is missing.'),
91 91 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
92 92 'This pull request cannot be updated because the source '
93 93 'reference is missing.'),
94 94 }
95 REF_TYPES = ['bookmark', 'book', 'tag', 'branch']
96 UPDATABLE_REF_TYPES = ['bookmark', 'book', 'branch']
95 97
96 98 def __get_pull_request(self, pull_request):
97 99 return self._get_instance((
98 100 PullRequest, PullRequestVersion), pull_request)
99 101
100 102 def _check_perms(self, perms, pull_request, user, api=False):
101 103 if not api:
102 104 return h.HasRepoPermissionAny(*perms)(
103 105 user=user, repo_name=pull_request.target_repo.repo_name)
104 106 else:
105 107 return h.HasRepoPermissionAnyApi(*perms)(
106 108 user=user, repo_name=pull_request.target_repo.repo_name)
107 109
108 110 def check_user_read(self, pull_request, user, api=False):
109 111 _perms = ('repository.admin', 'repository.write', 'repository.read',)
110 112 return self._check_perms(_perms, pull_request, user, api)
111 113
112 114 def check_user_merge(self, pull_request, user, api=False):
113 115 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
114 116 return self._check_perms(_perms, pull_request, user, api)
115 117
116 118 def check_user_update(self, pull_request, user, api=False):
117 119 owner = user.user_id == pull_request.user_id
118 120 return self.check_user_merge(pull_request, user, api) or owner
119 121
120 122 def check_user_delete(self, pull_request, user):
121 123 owner = user.user_id == pull_request.user_id
122 124 _perms = ('repository.admin',)
123 125 return self._check_perms(_perms, pull_request, user) or owner
124 126
125 127 def check_user_change_status(self, pull_request, user, api=False):
126 128 reviewer = user.user_id in [x.user_id for x in
127 129 pull_request.reviewers]
128 130 return self.check_user_update(pull_request, user, api) or reviewer
129 131
130 132 def check_user_comment(self, pull_request, user):
131 133 owner = user.user_id == pull_request.user_id
132 134 return self.check_user_read(pull_request, user) or owner
133 135
134 136 def get(self, pull_request):
135 137 return self.__get_pull_request(pull_request)
136 138
137 139 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
138 140 opened_by=None, order_by=None,
139 141 order_dir='desc'):
140 142 repo = None
141 143 if repo_name:
142 144 repo = self._get_repo(repo_name)
143 145
144 146 q = PullRequest.query()
145 147
146 148 # source or target
147 149 if repo and source:
148 150 q = q.filter(PullRequest.source_repo == repo)
149 151 elif repo:
150 152 q = q.filter(PullRequest.target_repo == repo)
151 153
152 154 # closed,opened
153 155 if statuses:
154 156 q = q.filter(PullRequest.status.in_(statuses))
155 157
156 158 # opened by filter
157 159 if opened_by:
158 160 q = q.filter(PullRequest.user_id.in_(opened_by))
159 161
160 162 if order_by:
161 163 order_map = {
162 164 'name_raw': PullRequest.pull_request_id,
163 165 'title': PullRequest.title,
164 166 'updated_on_raw': PullRequest.updated_on,
165 167 'target_repo': PullRequest.target_repo_id
166 168 }
167 169 if order_dir == 'asc':
168 170 q = q.order_by(order_map[order_by].asc())
169 171 else:
170 172 q = q.order_by(order_map[order_by].desc())
171 173
172 174 return q
173 175
174 176 def count_all(self, repo_name, source=False, statuses=None,
175 177 opened_by=None):
176 178 """
177 179 Count the number of pull requests for a specific repository.
178 180
179 181 :param repo_name: target or source repo
180 182 :param source: boolean flag to specify if repo_name refers to source
181 183 :param statuses: list of pull request statuses
182 184 :param opened_by: author user of the pull request
183 185 :returns: int number of pull requests
184 186 """
185 187 q = self._prepare_get_all_query(
186 188 repo_name, source=source, statuses=statuses, opened_by=opened_by)
187 189
188 190 return q.count()
189 191
190 192 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
191 193 offset=0, length=None, order_by=None, order_dir='desc'):
192 194 """
193 195 Get all pull requests for a specific repository.
194 196
195 197 :param repo_name: target or source repo
196 198 :param source: boolean flag to specify if repo_name refers to source
197 199 :param statuses: list of pull request statuses
198 200 :param opened_by: author user of the pull request
199 201 :param offset: pagination offset
200 202 :param length: length of returned list
201 203 :param order_by: order of the returned list
202 204 :param order_dir: 'asc' or 'desc' ordering direction
203 205 :returns: list of pull requests
204 206 """
205 207 q = self._prepare_get_all_query(
206 208 repo_name, source=source, statuses=statuses, opened_by=opened_by,
207 209 order_by=order_by, order_dir=order_dir)
208 210
209 211 if length:
210 212 pull_requests = q.limit(length).offset(offset).all()
211 213 else:
212 214 pull_requests = q.all()
213 215
214 216 return pull_requests
215 217
216 218 def count_awaiting_review(self, repo_name, source=False, statuses=None,
217 219 opened_by=None):
218 220 """
219 221 Count the number of pull requests for a specific repository that are
220 222 awaiting review.
221 223
222 224 :param repo_name: target or source repo
223 225 :param source: boolean flag to specify if repo_name refers to source
224 226 :param statuses: list of pull request statuses
225 227 :param opened_by: author user of the pull request
226 228 :returns: int number of pull requests
227 229 """
228 230 pull_requests = self.get_awaiting_review(
229 231 repo_name, source=source, statuses=statuses, opened_by=opened_by)
230 232
231 233 return len(pull_requests)
232 234
233 235 def get_awaiting_review(self, repo_name, source=False, statuses=None,
234 236 opened_by=None, offset=0, length=None,
235 237 order_by=None, order_dir='desc'):
236 238 """
237 239 Get all pull requests for a specific repository that are awaiting
238 240 review.
239 241
240 242 :param repo_name: target or source repo
241 243 :param source: boolean flag to specify if repo_name refers to source
242 244 :param statuses: list of pull request statuses
243 245 :param opened_by: author user of the pull request
244 246 :param offset: pagination offset
245 247 :param length: length of returned list
246 248 :param order_by: order of the returned list
247 249 :param order_dir: 'asc' or 'desc' ordering direction
248 250 :returns: list of pull requests
249 251 """
250 252 pull_requests = self.get_all(
251 253 repo_name, source=source, statuses=statuses, opened_by=opened_by,
252 254 order_by=order_by, order_dir=order_dir)
253 255
254 256 _filtered_pull_requests = []
255 257 for pr in pull_requests:
256 258 status = pr.calculated_review_status()
257 259 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
258 260 ChangesetStatus.STATUS_UNDER_REVIEW]:
259 261 _filtered_pull_requests.append(pr)
260 262 if length:
261 263 return _filtered_pull_requests[offset:offset+length]
262 264 else:
263 265 return _filtered_pull_requests
264 266
265 267 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
266 268 opened_by=None, user_id=None):
267 269 """
268 270 Count the number of pull requests for a specific repository that are
269 271 awaiting review from a specific user.
270 272
271 273 :param repo_name: target or source repo
272 274 :param source: boolean flag to specify if repo_name refers to source
273 275 :param statuses: list of pull request statuses
274 276 :param opened_by: author user of the pull request
275 277 :param user_id: reviewer user of the pull request
276 278 :returns: int number of pull requests
277 279 """
278 280 pull_requests = self.get_awaiting_my_review(
279 281 repo_name, source=source, statuses=statuses, opened_by=opened_by,
280 282 user_id=user_id)
281 283
282 284 return len(pull_requests)
283 285
284 286 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
285 287 opened_by=None, user_id=None, offset=0,
286 288 length=None, order_by=None, order_dir='desc'):
287 289 """
288 290 Get all pull requests for a specific repository that are awaiting
289 291 review from a specific user.
290 292
291 293 :param repo_name: target or source repo
292 294 :param source: boolean flag to specify if repo_name refers to source
293 295 :param statuses: list of pull request statuses
294 296 :param opened_by: author user of the pull request
295 297 :param user_id: reviewer user of the pull request
296 298 :param offset: pagination offset
297 299 :param length: length of returned list
298 300 :param order_by: order of the returned list
299 301 :param order_dir: 'asc' or 'desc' ordering direction
300 302 :returns: list of pull requests
301 303 """
302 304 pull_requests = self.get_all(
303 305 repo_name, source=source, statuses=statuses, opened_by=opened_by,
304 306 order_by=order_by, order_dir=order_dir)
305 307
306 308 _my = PullRequestModel().get_not_reviewed(user_id)
307 309 my_participation = []
308 310 for pr in pull_requests:
309 311 if pr in _my:
310 312 my_participation.append(pr)
311 313 _filtered_pull_requests = my_participation
312 314 if length:
313 315 return _filtered_pull_requests[offset:offset+length]
314 316 else:
315 317 return _filtered_pull_requests
316 318
317 319 def get_not_reviewed(self, user_id):
318 320 return [
319 321 x.pull_request for x in PullRequestReviewers.query().filter(
320 322 PullRequestReviewers.user_id == user_id).all()
321 323 ]
322 324
323 325 def _prepare_participating_query(self, user_id=None, statuses=None,
324 326 order_by=None, order_dir='desc'):
325 327 q = PullRequest.query()
326 328 if user_id:
327 329 reviewers_subquery = Session().query(
328 330 PullRequestReviewers.pull_request_id).filter(
329 331 PullRequestReviewers.user_id == user_id).subquery()
330 332 user_filter = or_(
331 333 PullRequest.user_id == user_id,
332 334 PullRequest.pull_request_id.in_(reviewers_subquery)
333 335 )
334 336 q = PullRequest.query().filter(user_filter)
335 337
336 338 # closed,opened
337 339 if statuses:
338 340 q = q.filter(PullRequest.status.in_(statuses))
339 341
340 342 if order_by:
341 343 order_map = {
342 344 'name_raw': PullRequest.pull_request_id,
343 345 'title': PullRequest.title,
344 346 'updated_on_raw': PullRequest.updated_on,
345 347 'target_repo': PullRequest.target_repo_id
346 348 }
347 349 if order_dir == 'asc':
348 350 q = q.order_by(order_map[order_by].asc())
349 351 else:
350 352 q = q.order_by(order_map[order_by].desc())
351 353
352 354 return q
353 355
354 356 def count_im_participating_in(self, user_id=None, statuses=None):
355 357 q = self._prepare_participating_query(user_id, statuses=statuses)
356 358 return q.count()
357 359
358 360 def get_im_participating_in(
359 361 self, user_id=None, statuses=None, offset=0,
360 362 length=None, order_by=None, order_dir='desc'):
361 363 """
362 364 Get all Pull requests that i'm participating in, or i have opened
363 365 """
364 366
365 367 q = self._prepare_participating_query(
366 368 user_id, statuses=statuses, order_by=order_by,
367 369 order_dir=order_dir)
368 370
369 371 if length:
370 372 pull_requests = q.limit(length).offset(offset).all()
371 373 else:
372 374 pull_requests = q.all()
373 375
374 376 return pull_requests
375 377
376 378 def get_versions(self, pull_request):
377 379 """
378 380 returns version of pull request sorted by ID descending
379 381 """
380 382 return PullRequestVersion.query()\
381 383 .filter(PullRequestVersion.pull_request == pull_request)\
382 384 .order_by(PullRequestVersion.pull_request_version_id.asc())\
383 385 .all()
384 386
385 387 def get_pr_version(self, pull_request_id, version=None):
386 388 at_version = None
387 389
388 390 if version and version == 'latest':
389 391 pull_request_ver = PullRequest.get(pull_request_id)
390 392 pull_request_obj = pull_request_ver
391 393 _org_pull_request_obj = pull_request_obj
392 394 at_version = 'latest'
393 395 elif version:
394 396 pull_request_ver = PullRequestVersion.get_or_404(version)
395 397 pull_request_obj = pull_request_ver
396 398 _org_pull_request_obj = pull_request_ver.pull_request
397 399 at_version = pull_request_ver.pull_request_version_id
398 400 else:
399 401 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
400 402 pull_request_id)
401 403
402 404 pull_request_display_obj = PullRequest.get_pr_display_object(
403 405 pull_request_obj, _org_pull_request_obj)
404 406
405 407 return _org_pull_request_obj, pull_request_obj, \
406 408 pull_request_display_obj, at_version
407 409
408 410 def create(self, created_by, source_repo, source_ref, target_repo,
409 411 target_ref, revisions, reviewers, title, description=None,
410 412 description_renderer=None,
411 413 reviewer_data=None, translator=None, auth_user=None):
412 414 translator = translator or get_current_request().translate
413 415
414 416 created_by_user = self._get_user(created_by)
415 417 auth_user = auth_user or created_by_user.AuthUser()
416 418 source_repo = self._get_repo(source_repo)
417 419 target_repo = self._get_repo(target_repo)
418 420
419 421 pull_request = PullRequest()
420 422 pull_request.source_repo = source_repo
421 423 pull_request.source_ref = source_ref
422 424 pull_request.target_repo = target_repo
423 425 pull_request.target_ref = target_ref
424 426 pull_request.revisions = revisions
425 427 pull_request.title = title
426 428 pull_request.description = description
427 429 pull_request.description_renderer = description_renderer
428 430 pull_request.author = created_by_user
429 431 pull_request.reviewer_data = reviewer_data
430 432
431 433 Session().add(pull_request)
432 434 Session().flush()
433 435
434 436 reviewer_ids = set()
435 437 # members / reviewers
436 438 for reviewer_object in reviewers:
437 439 user_id, reasons, mandatory, rules = reviewer_object
438 440 user = self._get_user(user_id)
439 441
440 442 # skip duplicates
441 443 if user.user_id in reviewer_ids:
442 444 continue
443 445
444 446 reviewer_ids.add(user.user_id)
445 447
446 448 reviewer = PullRequestReviewers()
447 449 reviewer.user = user
448 450 reviewer.pull_request = pull_request
449 451 reviewer.reasons = reasons
450 452 reviewer.mandatory = mandatory
451 453
452 454 # NOTE(marcink): pick only first rule for now
453 455 rule_id = list(rules)[0] if rules else None
454 456 rule = RepoReviewRule.get(rule_id) if rule_id else None
455 457 if rule:
456 458 review_group = rule.user_group_vote_rule(user_id)
457 459 # we check if this particular reviewer is member of a voting group
458 460 if review_group:
459 461 # NOTE(marcink):
460 462 # can be that user is member of more but we pick the first same,
461 463 # same as default reviewers algo
462 464 review_group = review_group[0]
463 465
464 466 rule_data = {
465 467 'rule_name':
466 468 rule.review_rule_name,
467 469 'rule_user_group_entry_id':
468 470 review_group.repo_review_rule_users_group_id,
469 471 'rule_user_group_name':
470 472 review_group.users_group.users_group_name,
471 473 'rule_user_group_members':
472 474 [x.user.username for x in review_group.users_group.members],
473 475 'rule_user_group_members_id':
474 476 [x.user.user_id for x in review_group.users_group.members],
475 477 }
476 478 # e.g {'vote_rule': -1, 'mandatory': True}
477 479 rule_data.update(review_group.rule_data())
478 480
479 481 reviewer.rule_data = rule_data
480 482
481 483 Session().add(reviewer)
482 484 Session().flush()
483 485
484 486 # Set approval status to "Under Review" for all commits which are
485 487 # part of this pull request.
486 488 ChangesetStatusModel().set_status(
487 489 repo=target_repo,
488 490 status=ChangesetStatus.STATUS_UNDER_REVIEW,
489 491 user=created_by_user,
490 492 pull_request=pull_request
491 493 )
492 494 # we commit early at this point. This has to do with a fact
493 495 # that before queries do some row-locking. And because of that
494 496 # we need to commit and finish transation before below validate call
495 497 # that for large repos could be long resulting in long row locks
496 498 Session().commit()
497 499
498 500 # prepare workspace, and run initial merge simulation
499 501 MergeCheck.validate(
500 502 pull_request, auth_user=auth_user, translator=translator)
501 503
502 504 self.notify_reviewers(pull_request, reviewer_ids)
503 505 self._trigger_pull_request_hook(
504 506 pull_request, created_by_user, 'create')
505 507
506 508 creation_data = pull_request.get_api_data(with_merge_state=False)
507 509 self._log_audit_action(
508 510 'repo.pull_request.create', {'data': creation_data},
509 511 auth_user, pull_request)
510 512
511 513 return pull_request
512 514
513 515 def _trigger_pull_request_hook(self, pull_request, user, action):
514 516 pull_request = self.__get_pull_request(pull_request)
515 517 target_scm = pull_request.target_repo.scm_instance()
516 518 if action == 'create':
517 519 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
518 520 elif action == 'merge':
519 521 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
520 522 elif action == 'close':
521 523 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
522 524 elif action == 'review_status_change':
523 525 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
524 526 elif action == 'update':
525 527 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
526 528 else:
527 529 return
528 530
529 531 trigger_hook(
530 532 username=user.username,
531 533 repo_name=pull_request.target_repo.repo_name,
532 534 repo_alias=target_scm.alias,
533 535 pull_request=pull_request)
534 536
535 537 def _get_commit_ids(self, pull_request):
536 538 """
537 539 Return the commit ids of the merged pull request.
538 540
539 541 This method is not dealing correctly yet with the lack of autoupdates
540 542 nor with the implicit target updates.
541 543 For example: if a commit in the source repo is already in the target it
542 544 will be reported anyways.
543 545 """
544 546 merge_rev = pull_request.merge_rev
545 547 if merge_rev is None:
546 548 raise ValueError('This pull request was not merged yet')
547 549
548 550 commit_ids = list(pull_request.revisions)
549 551 if merge_rev not in commit_ids:
550 552 commit_ids.append(merge_rev)
551 553
552 554 return commit_ids
553 555
554 556 def merge_repo(self, pull_request, user, extras):
555 557 log.debug("Merging pull request %s", pull_request.pull_request_id)
556 558 extras['user_agent'] = 'internal-merge'
557 559 merge_state = self._merge_pull_request(pull_request, user, extras)
558 560 if merge_state.executed:
559 561 log.debug("Merge was successful, updating the pull request comments.")
560 562 self._comment_and_close_pr(pull_request, user, merge_state)
561 563
562 564 self._log_audit_action(
563 565 'repo.pull_request.merge',
564 566 {'merge_state': merge_state.__dict__},
565 567 user, pull_request)
566 568
567 569 else:
568 570 log.warn("Merge failed, not updating the pull request.")
569 571 return merge_state
570 572
571 573 def _merge_pull_request(self, pull_request, user, extras, merge_msg=None):
572 574 target_vcs = pull_request.target_repo.scm_instance()
573 575 source_vcs = pull_request.source_repo.scm_instance()
574 576
575 577 message = safe_unicode(merge_msg or vcs_settings.MERGE_MESSAGE_TMPL).format(
576 578 pr_id=pull_request.pull_request_id,
577 579 pr_title=pull_request.title,
578 580 source_repo=source_vcs.name,
579 581 source_ref_name=pull_request.source_ref_parts.name,
580 582 target_repo=target_vcs.name,
581 583 target_ref_name=pull_request.target_ref_parts.name,
582 584 )
583 585
584 586 workspace_id = self._workspace_id(pull_request)
585 587 repo_id = pull_request.target_repo.repo_id
586 588 use_rebase = self._use_rebase_for_merging(pull_request)
587 589 close_branch = self._close_branch_before_merging(pull_request)
588 590
589 591 target_ref = self._refresh_reference(
590 592 pull_request.target_ref_parts, target_vcs)
591 593
592 594 callback_daemon, extras = prepare_callback_daemon(
593 595 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
594 596 host=vcs_settings.HOOKS_HOST,
595 597 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
596 598
597 599 with callback_daemon:
598 600 # TODO: johbo: Implement a clean way to run a config_override
599 601 # for a single call.
600 602 target_vcs.config.set(
601 603 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
602 604
603 605 user_name = user.short_contact
604 606 merge_state = target_vcs.merge(
605 607 repo_id, workspace_id, target_ref, source_vcs,
606 608 pull_request.source_ref_parts,
607 609 user_name=user_name, user_email=user.email,
608 610 message=message, use_rebase=use_rebase,
609 611 close_branch=close_branch)
610 612 return merge_state
611 613
612 614 def _comment_and_close_pr(self, pull_request, user, merge_state, close_msg=None):
613 615 pull_request.merge_rev = merge_state.merge_ref.commit_id
614 616 pull_request.updated_on = datetime.datetime.now()
615 617 close_msg = close_msg or 'Pull request merged and closed'
616 618
617 619 CommentsModel().create(
618 620 text=safe_unicode(close_msg),
619 621 repo=pull_request.target_repo.repo_id,
620 622 user=user.user_id,
621 623 pull_request=pull_request.pull_request_id,
622 624 f_path=None,
623 625 line_no=None,
624 626 closing_pr=True
625 627 )
626 628
627 629 Session().add(pull_request)
628 630 Session().flush()
629 631 # TODO: paris: replace invalidation with less radical solution
630 632 ScmModel().mark_for_invalidation(
631 633 pull_request.target_repo.repo_name)
632 634 self._trigger_pull_request_hook(pull_request, user, 'merge')
633 635
634 636 def has_valid_update_type(self, pull_request):
635 637 source_ref_type = pull_request.source_ref_parts.type
636 return source_ref_type in ['book', 'branch', 'tag']
638 return source_ref_type in self.REF_TYPES
637 639
638 640 def update_commits(self, pull_request):
639 641 """
640 642 Get the updated list of commits for the pull request
641 643 and return the new pull request version and the list
642 644 of commits processed by this update action
643 645 """
644 646 pull_request = self.__get_pull_request(pull_request)
645 647 source_ref_type = pull_request.source_ref_parts.type
646 648 source_ref_name = pull_request.source_ref_parts.name
647 649 source_ref_id = pull_request.source_ref_parts.commit_id
648 650
649 651 target_ref_type = pull_request.target_ref_parts.type
650 652 target_ref_name = pull_request.target_ref_parts.name
651 653 target_ref_id = pull_request.target_ref_parts.commit_id
652 654
653 655 if not self.has_valid_update_type(pull_request):
654 656 log.debug(
655 657 "Skipping update of pull request %s due to ref type: %s",
656 658 pull_request, source_ref_type)
657 659 return UpdateResponse(
658 660 executed=False,
659 661 reason=UpdateFailureReason.WRONG_REF_TYPE,
660 662 old=pull_request, new=None, changes=None,
661 663 source_changed=False, target_changed=False)
662 664
663 665 # source repo
664 666 source_repo = pull_request.source_repo.scm_instance()
665 667 try:
666 668 source_commit = source_repo.get_commit(commit_id=source_ref_name)
667 669 except CommitDoesNotExistError:
668 670 return UpdateResponse(
669 671 executed=False,
670 672 reason=UpdateFailureReason.MISSING_SOURCE_REF,
671 673 old=pull_request, new=None, changes=None,
672 674 source_changed=False, target_changed=False)
673 675
674 676 source_changed = source_ref_id != source_commit.raw_id
675 677
676 678 # target repo
677 679 target_repo = pull_request.target_repo.scm_instance()
678 680 try:
679 681 target_commit = target_repo.get_commit(commit_id=target_ref_name)
680 682 except CommitDoesNotExistError:
681 683 return UpdateResponse(
682 684 executed=False,
683 685 reason=UpdateFailureReason.MISSING_TARGET_REF,
684 686 old=pull_request, new=None, changes=None,
685 687 source_changed=False, target_changed=False)
686 688 target_changed = target_ref_id != target_commit.raw_id
687 689
688 690 if not (source_changed or target_changed):
689 691 log.debug("Nothing changed in pull request %s", pull_request)
690 692 return UpdateResponse(
691 693 executed=False,
692 694 reason=UpdateFailureReason.NO_CHANGE,
693 695 old=pull_request, new=None, changes=None,
694 696 source_changed=target_changed, target_changed=source_changed)
695 697
696 698 change_in_found = 'target repo' if target_changed else 'source repo'
697 699 log.debug('Updating pull request because of change in %s detected',
698 700 change_in_found)
699 701
700 702 # Finally there is a need for an update, in case of source change
701 703 # we create a new version, else just an update
702 704 if source_changed:
703 705 pull_request_version = self._create_version_from_snapshot(pull_request)
704 706 self._link_comments_to_version(pull_request_version)
705 707 else:
706 708 try:
707 709 ver = pull_request.versions[-1]
708 710 except IndexError:
709 711 ver = None
710 712
711 713 pull_request.pull_request_version_id = \
712 714 ver.pull_request_version_id if ver else None
713 715 pull_request_version = pull_request
714 716
715 717 try:
716 if target_ref_type in ('tag', 'branch', 'book'):
718 if target_ref_type in self.REF_TYPES:
717 719 target_commit = target_repo.get_commit(target_ref_name)
718 720 else:
719 721 target_commit = target_repo.get_commit(target_ref_id)
720 722 except CommitDoesNotExistError:
721 723 return UpdateResponse(
722 724 executed=False,
723 725 reason=UpdateFailureReason.MISSING_TARGET_REF,
724 726 old=pull_request, new=None, changes=None,
725 727 source_changed=source_changed, target_changed=target_changed)
726 728
727 729 # re-compute commit ids
728 730 old_commit_ids = pull_request.revisions
729 731 pre_load = ["author", "branch", "date", "message"]
730 732 commit_ranges = target_repo.compare(
731 733 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
732 734 pre_load=pre_load)
733 735
734 736 ancestor = target_repo.get_common_ancestor(
735 737 target_commit.raw_id, source_commit.raw_id, source_repo)
736 738
737 739 pull_request.source_ref = '%s:%s:%s' % (
738 740 source_ref_type, source_ref_name, source_commit.raw_id)
739 741 pull_request.target_ref = '%s:%s:%s' % (
740 742 target_ref_type, target_ref_name, ancestor)
741 743
742 744 pull_request.revisions = [
743 745 commit.raw_id for commit in reversed(commit_ranges)]
744 746 pull_request.updated_on = datetime.datetime.now()
745 747 Session().add(pull_request)
746 748 new_commit_ids = pull_request.revisions
747 749
748 750 old_diff_data, new_diff_data = self._generate_update_diffs(
749 751 pull_request, pull_request_version)
750 752
751 753 # calculate commit and file changes
752 754 changes = self._calculate_commit_id_changes(
753 755 old_commit_ids, new_commit_ids)
754 756 file_changes = self._calculate_file_changes(
755 757 old_diff_data, new_diff_data)
756 758
757 759 # set comments as outdated if DIFFS changed
758 760 CommentsModel().outdate_comments(
759 761 pull_request, old_diff_data=old_diff_data,
760 762 new_diff_data=new_diff_data)
761 763
762 764 commit_changes = (changes.added or changes.removed)
763 765 file_node_changes = (
764 766 file_changes.added or file_changes.modified or file_changes.removed)
765 767 pr_has_changes = commit_changes or file_node_changes
766 768
767 769 # Add an automatic comment to the pull request, in case
768 770 # anything has changed
769 771 if pr_has_changes:
770 772 update_comment = CommentsModel().create(
771 773 text=self._render_update_message(changes, file_changes),
772 774 repo=pull_request.target_repo,
773 775 user=pull_request.author,
774 776 pull_request=pull_request,
775 777 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
776 778
777 779 # Update status to "Under Review" for added commits
778 780 for commit_id in changes.added:
779 781 ChangesetStatusModel().set_status(
780 782 repo=pull_request.source_repo,
781 783 status=ChangesetStatus.STATUS_UNDER_REVIEW,
782 784 comment=update_comment,
783 785 user=pull_request.author,
784 786 pull_request=pull_request,
785 787 revision=commit_id)
786 788
787 789 log.debug(
788 790 'Updated pull request %s, added_ids: %s, common_ids: %s, '
789 791 'removed_ids: %s', pull_request.pull_request_id,
790 792 changes.added, changes.common, changes.removed)
791 793 log.debug(
792 794 'Updated pull request with the following file changes: %s',
793 795 file_changes)
794 796
795 797 log.info(
796 798 "Updated pull request %s from commit %s to commit %s, "
797 799 "stored new version %s of this pull request.",
798 800 pull_request.pull_request_id, source_ref_id,
799 801 pull_request.source_ref_parts.commit_id,
800 802 pull_request_version.pull_request_version_id)
801 803 Session().commit()
802 804 self._trigger_pull_request_hook(
803 805 pull_request, pull_request.author, 'update')
804 806
805 807 return UpdateResponse(
806 808 executed=True, reason=UpdateFailureReason.NONE,
807 809 old=pull_request, new=pull_request_version, changes=changes,
808 810 source_changed=source_changed, target_changed=target_changed)
809 811
810 812 def _create_version_from_snapshot(self, pull_request):
811 813 version = PullRequestVersion()
812 814 version.title = pull_request.title
813 815 version.description = pull_request.description
814 816 version.status = pull_request.status
815 817 version.created_on = datetime.datetime.now()
816 818 version.updated_on = pull_request.updated_on
817 819 version.user_id = pull_request.user_id
818 820 version.source_repo = pull_request.source_repo
819 821 version.source_ref = pull_request.source_ref
820 822 version.target_repo = pull_request.target_repo
821 823 version.target_ref = pull_request.target_ref
822 824
823 825 version._last_merge_source_rev = pull_request._last_merge_source_rev
824 826 version._last_merge_target_rev = pull_request._last_merge_target_rev
825 827 version.last_merge_status = pull_request.last_merge_status
826 828 version.shadow_merge_ref = pull_request.shadow_merge_ref
827 829 version.merge_rev = pull_request.merge_rev
828 830 version.reviewer_data = pull_request.reviewer_data
829 831
830 832 version.revisions = pull_request.revisions
831 833 version.pull_request = pull_request
832 834 Session().add(version)
833 835 Session().flush()
834 836
835 837 return version
836 838
837 839 def _generate_update_diffs(self, pull_request, pull_request_version):
838 840
839 841 diff_context = (
840 842 self.DIFF_CONTEXT +
841 843 CommentsModel.needed_extra_diff_context())
842 844 hide_whitespace_changes = False
843 845 source_repo = pull_request_version.source_repo
844 846 source_ref_id = pull_request_version.source_ref_parts.commit_id
845 847 target_ref_id = pull_request_version.target_ref_parts.commit_id
846 848 old_diff = self._get_diff_from_pr_or_version(
847 849 source_repo, source_ref_id, target_ref_id,
848 850 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
849 851
850 852 source_repo = pull_request.source_repo
851 853 source_ref_id = pull_request.source_ref_parts.commit_id
852 854 target_ref_id = pull_request.target_ref_parts.commit_id
853 855
854 856 new_diff = self._get_diff_from_pr_or_version(
855 857 source_repo, source_ref_id, target_ref_id,
856 858 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
857 859
858 860 old_diff_data = diffs.DiffProcessor(old_diff)
859 861 old_diff_data.prepare()
860 862 new_diff_data = diffs.DiffProcessor(new_diff)
861 863 new_diff_data.prepare()
862 864
863 865 return old_diff_data, new_diff_data
864 866
865 867 def _link_comments_to_version(self, pull_request_version):
866 868 """
867 869 Link all unlinked comments of this pull request to the given version.
868 870
869 871 :param pull_request_version: The `PullRequestVersion` to which
870 872 the comments shall be linked.
871 873
872 874 """
873 875 pull_request = pull_request_version.pull_request
874 876 comments = ChangesetComment.query()\
875 877 .filter(
876 878 # TODO: johbo: Should we query for the repo at all here?
877 879 # Pending decision on how comments of PRs are to be related
878 880 # to either the source repo, the target repo or no repo at all.
879 881 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
880 882 ChangesetComment.pull_request == pull_request,
881 883 ChangesetComment.pull_request_version == None)\
882 884 .order_by(ChangesetComment.comment_id.asc())
883 885
884 886 # TODO: johbo: Find out why this breaks if it is done in a bulk
885 887 # operation.
886 888 for comment in comments:
887 889 comment.pull_request_version_id = (
888 890 pull_request_version.pull_request_version_id)
889 891 Session().add(comment)
890 892
891 893 def _calculate_commit_id_changes(self, old_ids, new_ids):
892 894 added = [x for x in new_ids if x not in old_ids]
893 895 common = [x for x in new_ids if x in old_ids]
894 896 removed = [x for x in old_ids if x not in new_ids]
895 897 total = new_ids
896 898 return ChangeTuple(added, common, removed, total)
897 899
898 900 def _calculate_file_changes(self, old_diff_data, new_diff_data):
899 901
900 902 old_files = OrderedDict()
901 903 for diff_data in old_diff_data.parsed_diff:
902 904 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
903 905
904 906 added_files = []
905 907 modified_files = []
906 908 removed_files = []
907 909 for diff_data in new_diff_data.parsed_diff:
908 910 new_filename = diff_data['filename']
909 911 new_hash = md5_safe(diff_data['raw_diff'])
910 912
911 913 old_hash = old_files.get(new_filename)
912 914 if not old_hash:
913 915 # file is not present in old diff, means it's added
914 916 added_files.append(new_filename)
915 917 else:
916 918 if new_hash != old_hash:
917 919 modified_files.append(new_filename)
918 920 # now remove a file from old, since we have seen it already
919 921 del old_files[new_filename]
920 922
921 923 # removed files is when there are present in old, but not in NEW,
922 924 # since we remove old files that are present in new diff, left-overs
923 925 # if any should be the removed files
924 926 removed_files.extend(old_files.keys())
925 927
926 928 return FileChangeTuple(added_files, modified_files, removed_files)
927 929
928 930 def _render_update_message(self, changes, file_changes):
929 931 """
930 932 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
931 933 so it's always looking the same disregarding on which default
932 934 renderer system is using.
933 935
934 936 :param changes: changes named tuple
935 937 :param file_changes: file changes named tuple
936 938
937 939 """
938 940 new_status = ChangesetStatus.get_status_lbl(
939 941 ChangesetStatus.STATUS_UNDER_REVIEW)
940 942
941 943 changed_files = (
942 944 file_changes.added + file_changes.modified + file_changes.removed)
943 945
944 946 params = {
945 947 'under_review_label': new_status,
946 948 'added_commits': changes.added,
947 949 'removed_commits': changes.removed,
948 950 'changed_files': changed_files,
949 951 'added_files': file_changes.added,
950 952 'modified_files': file_changes.modified,
951 953 'removed_files': file_changes.removed,
952 954 }
953 955 renderer = RstTemplateRenderer()
954 956 return renderer.render('pull_request_update.mako', **params)
955 957
956 958 def edit(self, pull_request, title, description, description_renderer, user):
957 959 pull_request = self.__get_pull_request(pull_request)
958 960 old_data = pull_request.get_api_data(with_merge_state=False)
959 961 if pull_request.is_closed():
960 962 raise ValueError('This pull request is closed')
961 963 if title:
962 964 pull_request.title = title
963 965 pull_request.description = description
964 966 pull_request.updated_on = datetime.datetime.now()
965 967 pull_request.description_renderer = description_renderer
966 968 Session().add(pull_request)
967 969 self._log_audit_action(
968 970 'repo.pull_request.edit', {'old_data': old_data},
969 971 user, pull_request)
970 972
971 973 def update_reviewers(self, pull_request, reviewer_data, user):
972 974 """
973 975 Update the reviewers in the pull request
974 976
975 977 :param pull_request: the pr to update
976 978 :param reviewer_data: list of tuples
977 979 [(user, ['reason1', 'reason2'], mandatory_flag, [rules])]
978 980 """
979 981 pull_request = self.__get_pull_request(pull_request)
980 982 if pull_request.is_closed():
981 983 raise ValueError('This pull request is closed')
982 984
983 985 reviewers = {}
984 986 for user_id, reasons, mandatory, rules in reviewer_data:
985 987 if isinstance(user_id, (int, basestring)):
986 988 user_id = self._get_user(user_id).user_id
987 989 reviewers[user_id] = {
988 990 'reasons': reasons, 'mandatory': mandatory}
989 991
990 992 reviewers_ids = set(reviewers.keys())
991 993 current_reviewers = PullRequestReviewers.query()\
992 994 .filter(PullRequestReviewers.pull_request ==
993 995 pull_request).all()
994 996 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
995 997
996 998 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
997 999 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
998 1000
999 1001 log.debug("Adding %s reviewers", ids_to_add)
1000 1002 log.debug("Removing %s reviewers", ids_to_remove)
1001 1003 changed = False
1002 1004 for uid in ids_to_add:
1003 1005 changed = True
1004 1006 _usr = self._get_user(uid)
1005 1007 reviewer = PullRequestReviewers()
1006 1008 reviewer.user = _usr
1007 1009 reviewer.pull_request = pull_request
1008 1010 reviewer.reasons = reviewers[uid]['reasons']
1009 1011 # NOTE(marcink): mandatory shouldn't be changed now
1010 1012 # reviewer.mandatory = reviewers[uid]['reasons']
1011 1013 Session().add(reviewer)
1012 1014 self._log_audit_action(
1013 1015 'repo.pull_request.reviewer.add', {'data': reviewer.get_dict()},
1014 1016 user, pull_request)
1015 1017
1016 1018 for uid in ids_to_remove:
1017 1019 changed = True
1018 1020 reviewers = PullRequestReviewers.query()\
1019 1021 .filter(PullRequestReviewers.user_id == uid,
1020 1022 PullRequestReviewers.pull_request == pull_request)\
1021 1023 .all()
1022 1024 # use .all() in case we accidentally added the same person twice
1023 1025 # this CAN happen due to the lack of DB checks
1024 1026 for obj in reviewers:
1025 1027 old_data = obj.get_dict()
1026 1028 Session().delete(obj)
1027 1029 self._log_audit_action(
1028 1030 'repo.pull_request.reviewer.delete',
1029 1031 {'old_data': old_data}, user, pull_request)
1030 1032
1031 1033 if changed:
1032 1034 pull_request.updated_on = datetime.datetime.now()
1033 1035 Session().add(pull_request)
1034 1036
1035 1037 self.notify_reviewers(pull_request, ids_to_add)
1036 1038 return ids_to_add, ids_to_remove
1037 1039
1038 1040 def get_url(self, pull_request, request=None, permalink=False):
1039 1041 if not request:
1040 1042 request = get_current_request()
1041 1043
1042 1044 if permalink:
1043 1045 return request.route_url(
1044 1046 'pull_requests_global',
1045 1047 pull_request_id=pull_request.pull_request_id,)
1046 1048 else:
1047 1049 return request.route_url('pullrequest_show',
1048 1050 repo_name=safe_str(pull_request.target_repo.repo_name),
1049 1051 pull_request_id=pull_request.pull_request_id,)
1050 1052
1051 1053 def get_shadow_clone_url(self, pull_request, request=None):
1052 1054 """
1053 1055 Returns qualified url pointing to the shadow repository. If this pull
1054 1056 request is closed there is no shadow repository and ``None`` will be
1055 1057 returned.
1056 1058 """
1057 1059 if pull_request.is_closed():
1058 1060 return None
1059 1061 else:
1060 1062 pr_url = urllib.unquote(self.get_url(pull_request, request=request))
1061 1063 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1062 1064
1063 1065 def notify_reviewers(self, pull_request, reviewers_ids):
1064 1066 # notification to reviewers
1065 1067 if not reviewers_ids:
1066 1068 return
1067 1069
1068 1070 pull_request_obj = pull_request
1069 1071 # get the current participants of this pull request
1070 1072 recipients = reviewers_ids
1071 1073 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1072 1074
1073 1075 pr_source_repo = pull_request_obj.source_repo
1074 1076 pr_target_repo = pull_request_obj.target_repo
1075 1077
1076 1078 pr_url = h.route_url('pullrequest_show',
1077 1079 repo_name=pr_target_repo.repo_name,
1078 1080 pull_request_id=pull_request_obj.pull_request_id,)
1079 1081
1080 1082 # set some variables for email notification
1081 1083 pr_target_repo_url = h.route_url(
1082 1084 'repo_summary', repo_name=pr_target_repo.repo_name)
1083 1085
1084 1086 pr_source_repo_url = h.route_url(
1085 1087 'repo_summary', repo_name=pr_source_repo.repo_name)
1086 1088
1087 1089 # pull request specifics
1088 1090 pull_request_commits = [
1089 1091 (x.raw_id, x.message)
1090 1092 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1091 1093
1092 1094 kwargs = {
1093 1095 'user': pull_request.author,
1094 1096 'pull_request': pull_request_obj,
1095 1097 'pull_request_commits': pull_request_commits,
1096 1098
1097 1099 'pull_request_target_repo': pr_target_repo,
1098 1100 'pull_request_target_repo_url': pr_target_repo_url,
1099 1101
1100 1102 'pull_request_source_repo': pr_source_repo,
1101 1103 'pull_request_source_repo_url': pr_source_repo_url,
1102 1104
1103 1105 'pull_request_url': pr_url,
1104 1106 }
1105 1107
1106 1108 # pre-generate the subject for notification itself
1107 1109 (subject,
1108 1110 _h, _e, # we don't care about those
1109 1111 body_plaintext) = EmailNotificationModel().render_email(
1110 1112 notification_type, **kwargs)
1111 1113
1112 1114 # create notification objects, and emails
1113 1115 NotificationModel().create(
1114 1116 created_by=pull_request.author,
1115 1117 notification_subject=subject,
1116 1118 notification_body=body_plaintext,
1117 1119 notification_type=notification_type,
1118 1120 recipients=recipients,
1119 1121 email_kwargs=kwargs,
1120 1122 )
1121 1123
1122 1124 def delete(self, pull_request, user):
1123 1125 pull_request = self.__get_pull_request(pull_request)
1124 1126 old_data = pull_request.get_api_data(with_merge_state=False)
1125 1127 self._cleanup_merge_workspace(pull_request)
1126 1128 self._log_audit_action(
1127 1129 'repo.pull_request.delete', {'old_data': old_data},
1128 1130 user, pull_request)
1129 1131 Session().delete(pull_request)
1130 1132
1131 1133 def close_pull_request(self, pull_request, user):
1132 1134 pull_request = self.__get_pull_request(pull_request)
1133 1135 self._cleanup_merge_workspace(pull_request)
1134 1136 pull_request.status = PullRequest.STATUS_CLOSED
1135 1137 pull_request.updated_on = datetime.datetime.now()
1136 1138 Session().add(pull_request)
1137 1139 self._trigger_pull_request_hook(
1138 1140 pull_request, pull_request.author, 'close')
1139 1141
1140 1142 pr_data = pull_request.get_api_data(with_merge_state=False)
1141 1143 self._log_audit_action(
1142 1144 'repo.pull_request.close', {'data': pr_data}, user, pull_request)
1143 1145
1144 1146 def close_pull_request_with_comment(
1145 1147 self, pull_request, user, repo, message=None, auth_user=None):
1146 1148
1147 1149 pull_request_review_status = pull_request.calculated_review_status()
1148 1150
1149 1151 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1150 1152 # approved only if we have voting consent
1151 1153 status = ChangesetStatus.STATUS_APPROVED
1152 1154 else:
1153 1155 status = ChangesetStatus.STATUS_REJECTED
1154 1156 status_lbl = ChangesetStatus.get_status_lbl(status)
1155 1157
1156 1158 default_message = (
1157 1159 'Closing with status change {transition_icon} {status}.'
1158 1160 ).format(transition_icon='>', status=status_lbl)
1159 1161 text = message or default_message
1160 1162
1161 1163 # create a comment, and link it to new status
1162 1164 comment = CommentsModel().create(
1163 1165 text=text,
1164 1166 repo=repo.repo_id,
1165 1167 user=user.user_id,
1166 1168 pull_request=pull_request.pull_request_id,
1167 1169 status_change=status_lbl,
1168 1170 status_change_type=status,
1169 1171 closing_pr=True,
1170 1172 auth_user=auth_user,
1171 1173 )
1172 1174
1173 1175 # calculate old status before we change it
1174 1176 old_calculated_status = pull_request.calculated_review_status()
1175 1177 ChangesetStatusModel().set_status(
1176 1178 repo.repo_id,
1177 1179 status,
1178 1180 user.user_id,
1179 1181 comment=comment,
1180 1182 pull_request=pull_request.pull_request_id
1181 1183 )
1182 1184
1183 1185 Session().flush()
1184 1186 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1185 1187 # we now calculate the status of pull request again, and based on that
1186 1188 # calculation trigger status change. This might happen in cases
1187 1189 # that non-reviewer admin closes a pr, which means his vote doesn't
1188 1190 # change the status, while if he's a reviewer this might change it.
1189 1191 calculated_status = pull_request.calculated_review_status()
1190 1192 if old_calculated_status != calculated_status:
1191 1193 self._trigger_pull_request_hook(
1192 1194 pull_request, user, 'review_status_change')
1193 1195
1194 1196 # finally close the PR
1195 1197 PullRequestModel().close_pull_request(
1196 1198 pull_request.pull_request_id, user)
1197 1199
1198 1200 return comment, status
1199 1201
1200 1202 def merge_status(self, pull_request, translator=None,
1201 1203 force_shadow_repo_refresh=False):
1202 1204 _ = translator or get_current_request().translate
1203 1205
1204 1206 if not self._is_merge_enabled(pull_request):
1205 1207 return False, _('Server-side pull request merging is disabled.')
1206 1208 if pull_request.is_closed():
1207 1209 return False, _('This pull request is closed.')
1208 1210 merge_possible, msg = self._check_repo_requirements(
1209 1211 target=pull_request.target_repo, source=pull_request.source_repo,
1210 1212 translator=_)
1211 1213 if not merge_possible:
1212 1214 return merge_possible, msg
1213 1215
1214 1216 try:
1215 1217 resp = self._try_merge(
1216 1218 pull_request,
1217 1219 force_shadow_repo_refresh=force_shadow_repo_refresh)
1218 1220 log.debug("Merge response: %s", resp)
1219 1221 status = resp.possible, resp.merge_status_message
1220 1222 except NotImplementedError:
1221 1223 status = False, _('Pull request merging is not supported.')
1222 1224
1223 1225 return status
1224 1226
1225 1227 def _check_repo_requirements(self, target, source, translator):
1226 1228 """
1227 1229 Check if `target` and `source` have compatible requirements.
1228 1230
1229 1231 Currently this is just checking for largefiles.
1230 1232 """
1231 1233 _ = translator
1232 1234 target_has_largefiles = self._has_largefiles(target)
1233 1235 source_has_largefiles = self._has_largefiles(source)
1234 1236 merge_possible = True
1235 1237 message = u''
1236 1238
1237 1239 if target_has_largefiles != source_has_largefiles:
1238 1240 merge_possible = False
1239 1241 if source_has_largefiles:
1240 1242 message = _(
1241 1243 'Target repository large files support is disabled.')
1242 1244 else:
1243 1245 message = _(
1244 1246 'Source repository large files support is disabled.')
1245 1247
1246 1248 return merge_possible, message
1247 1249
1248 1250 def _has_largefiles(self, repo):
1249 1251 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1250 1252 'extensions', 'largefiles')
1251 1253 return largefiles_ui and largefiles_ui[0].active
1252 1254
1253 1255 def _try_merge(self, pull_request, force_shadow_repo_refresh=False):
1254 1256 """
1255 1257 Try to merge the pull request and return the merge status.
1256 1258 """
1257 1259 log.debug(
1258 1260 "Trying out if the pull request %s can be merged. Force_refresh=%s",
1259 1261 pull_request.pull_request_id, force_shadow_repo_refresh)
1260 1262 target_vcs = pull_request.target_repo.scm_instance()
1261 1263 # Refresh the target reference.
1262 1264 try:
1263 1265 target_ref = self._refresh_reference(
1264 1266 pull_request.target_ref_parts, target_vcs)
1265 1267 except CommitDoesNotExistError:
1266 1268 merge_state = MergeResponse(
1267 1269 False, False, None, MergeFailureReason.MISSING_TARGET_REF,
1268 1270 metadata={'target_ref': pull_request.target_ref_parts})
1269 1271 return merge_state
1270 1272
1271 1273 target_locked = pull_request.target_repo.locked
1272 1274 if target_locked and target_locked[0]:
1273 1275 locked_by = 'user:{}'.format(target_locked[0])
1274 1276 log.debug("The target repository is locked by %s.", locked_by)
1275 1277 merge_state = MergeResponse(
1276 1278 False, False, None, MergeFailureReason.TARGET_IS_LOCKED,
1277 1279 metadata={'locked_by': locked_by})
1278 1280 elif force_shadow_repo_refresh or self._needs_merge_state_refresh(
1279 1281 pull_request, target_ref):
1280 1282 log.debug("Refreshing the merge status of the repository.")
1281 1283 merge_state = self._refresh_merge_state(
1282 1284 pull_request, target_vcs, target_ref)
1283 1285 else:
1284 1286 possible = pull_request.\
1285 1287 last_merge_status == MergeFailureReason.NONE
1286 1288 merge_state = MergeResponse(
1287 1289 possible, False, None, pull_request.last_merge_status)
1288 1290
1289 1291 return merge_state
1290 1292
1291 1293 def _refresh_reference(self, reference, vcs_repository):
1292 if reference.type in ('branch', 'book'):
1294 if reference.type in self.UPDATABLE_REF_TYPES:
1293 1295 name_or_id = reference.name
1294 1296 else:
1295 1297 name_or_id = reference.commit_id
1296 1298 refreshed_commit = vcs_repository.get_commit(name_or_id)
1297 1299 refreshed_reference = Reference(
1298 1300 reference.type, reference.name, refreshed_commit.raw_id)
1299 1301 return refreshed_reference
1300 1302
1301 1303 def _needs_merge_state_refresh(self, pull_request, target_reference):
1302 1304 return not(
1303 1305 pull_request.revisions and
1304 1306 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1305 1307 target_reference.commit_id == pull_request._last_merge_target_rev)
1306 1308
1307 1309 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1308 1310 workspace_id = self._workspace_id(pull_request)
1309 1311 source_vcs = pull_request.source_repo.scm_instance()
1310 1312 repo_id = pull_request.target_repo.repo_id
1311 1313 use_rebase = self._use_rebase_for_merging(pull_request)
1312 1314 close_branch = self._close_branch_before_merging(pull_request)
1313 1315 merge_state = target_vcs.merge(
1314 1316 repo_id, workspace_id,
1315 1317 target_reference, source_vcs, pull_request.source_ref_parts,
1316 1318 dry_run=True, use_rebase=use_rebase,
1317 1319 close_branch=close_branch)
1318 1320
1319 1321 # Do not store the response if there was an unknown error.
1320 1322 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1321 1323 pull_request._last_merge_source_rev = \
1322 1324 pull_request.source_ref_parts.commit_id
1323 1325 pull_request._last_merge_target_rev = target_reference.commit_id
1324 1326 pull_request.last_merge_status = merge_state.failure_reason
1325 1327 pull_request.shadow_merge_ref = merge_state.merge_ref
1326 1328 Session().add(pull_request)
1327 1329 Session().commit()
1328 1330
1329 1331 return merge_state
1330 1332
1331 1333 def _workspace_id(self, pull_request):
1332 1334 workspace_id = 'pr-%s' % pull_request.pull_request_id
1333 1335 return workspace_id
1334 1336
1335 1337 def generate_repo_data(self, repo, commit_id=None, branch=None,
1336 1338 bookmark=None, translator=None):
1337 1339 from rhodecode.model.repo import RepoModel
1338 1340
1339 1341 all_refs, selected_ref = \
1340 1342 self._get_repo_pullrequest_sources(
1341 1343 repo.scm_instance(), commit_id=commit_id,
1342 1344 branch=branch, bookmark=bookmark, translator=translator)
1343 1345
1344 1346 refs_select2 = []
1345 1347 for element in all_refs:
1346 1348 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1347 1349 refs_select2.append({'text': element[1], 'children': children})
1348 1350
1349 1351 return {
1350 1352 'user': {
1351 1353 'user_id': repo.user.user_id,
1352 1354 'username': repo.user.username,
1353 1355 'firstname': repo.user.first_name,
1354 1356 'lastname': repo.user.last_name,
1355 1357 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1356 1358 },
1357 1359 'name': repo.repo_name,
1358 1360 'link': RepoModel().get_url(repo),
1359 1361 'description': h.chop_at_smart(repo.description_safe, '\n'),
1360 1362 'refs': {
1361 1363 'all_refs': all_refs,
1362 1364 'selected_ref': selected_ref,
1363 1365 'select2_refs': refs_select2
1364 1366 }
1365 1367 }
1366 1368
1367 1369 def generate_pullrequest_title(self, source, source_ref, target):
1368 1370 return u'{source}#{at_ref} to {target}'.format(
1369 1371 source=source,
1370 1372 at_ref=source_ref,
1371 1373 target=target,
1372 1374 )
1373 1375
1374 1376 def _cleanup_merge_workspace(self, pull_request):
1375 1377 # Merging related cleanup
1376 1378 repo_id = pull_request.target_repo.repo_id
1377 1379 target_scm = pull_request.target_repo.scm_instance()
1378 1380 workspace_id = self._workspace_id(pull_request)
1379 1381
1380 1382 try:
1381 1383 target_scm.cleanup_merge_workspace(repo_id, workspace_id)
1382 1384 except NotImplementedError:
1383 1385 pass
1384 1386
1385 1387 def _get_repo_pullrequest_sources(
1386 1388 self, repo, commit_id=None, branch=None, bookmark=None,
1387 1389 translator=None):
1388 1390 """
1389 1391 Return a structure with repo's interesting commits, suitable for
1390 1392 the selectors in pullrequest controller
1391 1393
1392 1394 :param commit_id: a commit that must be in the list somehow
1393 1395 and selected by default
1394 1396 :param branch: a branch that must be in the list and selected
1395 1397 by default - even if closed
1396 1398 :param bookmark: a bookmark that must be in the list and selected
1397 1399 """
1398 1400 _ = translator or get_current_request().translate
1399 1401
1400 1402 commit_id = safe_str(commit_id) if commit_id else None
1401 1403 branch = safe_str(branch) if branch else None
1402 1404 bookmark = safe_str(bookmark) if bookmark else None
1403 1405
1404 1406 selected = None
1405 1407
1406 1408 # order matters: first source that has commit_id in it will be selected
1407 1409 sources = []
1408 1410 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1409 1411 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1410 1412
1411 1413 if commit_id:
1412 1414 ref_commit = (h.short_id(commit_id), commit_id)
1413 1415 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1414 1416
1415 1417 sources.append(
1416 1418 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1417 1419 )
1418 1420
1419 1421 groups = []
1420 1422 for group_key, ref_list, group_name, match in sources:
1421 1423 group_refs = []
1422 1424 for ref_name, ref_id in ref_list:
1423 1425 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1424 1426 group_refs.append((ref_key, ref_name))
1425 1427
1426 1428 if not selected:
1427 1429 if set([commit_id, match]) & set([ref_id, ref_name]):
1428 1430 selected = ref_key
1429 1431
1430 1432 if group_refs:
1431 1433 groups.append((group_refs, group_name))
1432 1434
1433 1435 if not selected:
1434 1436 ref = commit_id or branch or bookmark
1435 1437 if ref:
1436 1438 raise CommitDoesNotExistError(
1437 1439 'No commit refs could be found matching: %s' % ref)
1438 1440 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1439 1441 selected = 'branch:%s:%s' % (
1440 1442 repo.DEFAULT_BRANCH_NAME,
1441 1443 repo.branches[repo.DEFAULT_BRANCH_NAME]
1442 1444 )
1443 1445 elif repo.commit_ids:
1444 1446 # make the user select in this case
1445 1447 selected = None
1446 1448 else:
1447 1449 raise EmptyRepositoryError()
1448 1450 return groups, selected
1449 1451
1450 1452 def get_diff(self, source_repo, source_ref_id, target_ref_id,
1451 1453 hide_whitespace_changes, diff_context):
1452 1454
1453 1455 return self._get_diff_from_pr_or_version(
1454 1456 source_repo, source_ref_id, target_ref_id,
1455 1457 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1456 1458
1457 1459 def _get_diff_from_pr_or_version(
1458 1460 self, source_repo, source_ref_id, target_ref_id,
1459 1461 hide_whitespace_changes, diff_context):
1460 1462
1461 1463 target_commit = source_repo.get_commit(
1462 1464 commit_id=safe_str(target_ref_id))
1463 1465 source_commit = source_repo.get_commit(
1464 1466 commit_id=safe_str(source_ref_id))
1465 1467 if isinstance(source_repo, Repository):
1466 1468 vcs_repo = source_repo.scm_instance()
1467 1469 else:
1468 1470 vcs_repo = source_repo
1469 1471
1470 1472 # TODO: johbo: In the context of an update, we cannot reach
1471 1473 # the old commit anymore with our normal mechanisms. It needs
1472 1474 # some sort of special support in the vcs layer to avoid this
1473 1475 # workaround.
1474 1476 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1475 1477 vcs_repo.alias == 'git'):
1476 1478 source_commit.raw_id = safe_str(source_ref_id)
1477 1479
1478 1480 log.debug('calculating diff between '
1479 1481 'source_ref:%s and target_ref:%s for repo `%s`',
1480 1482 target_ref_id, source_ref_id,
1481 1483 safe_unicode(vcs_repo.path))
1482 1484
1483 1485 vcs_diff = vcs_repo.get_diff(
1484 1486 commit1=target_commit, commit2=source_commit,
1485 1487 ignore_whitespace=hide_whitespace_changes, context=diff_context)
1486 1488 return vcs_diff
1487 1489
1488 1490 def _is_merge_enabled(self, pull_request):
1489 1491 return self._get_general_setting(
1490 1492 pull_request, 'rhodecode_pr_merge_enabled')
1491 1493
1492 1494 def _use_rebase_for_merging(self, pull_request):
1493 1495 repo_type = pull_request.target_repo.repo_type
1494 1496 if repo_type == 'hg':
1495 1497 return self._get_general_setting(
1496 1498 pull_request, 'rhodecode_hg_use_rebase_for_merging')
1497 1499 elif repo_type == 'git':
1498 1500 return self._get_general_setting(
1499 1501 pull_request, 'rhodecode_git_use_rebase_for_merging')
1500 1502
1501 1503 return False
1502 1504
1503 1505 def _close_branch_before_merging(self, pull_request):
1504 1506 repo_type = pull_request.target_repo.repo_type
1505 1507 if repo_type == 'hg':
1506 1508 return self._get_general_setting(
1507 1509 pull_request, 'rhodecode_hg_close_branch_before_merging')
1508 1510 elif repo_type == 'git':
1509 1511 return self._get_general_setting(
1510 1512 pull_request, 'rhodecode_git_close_branch_before_merging')
1511 1513
1512 1514 return False
1513 1515
1514 1516 def _get_general_setting(self, pull_request, settings_key, default=False):
1515 1517 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1516 1518 settings = settings_model.get_general_settings()
1517 1519 return settings.get(settings_key, default)
1518 1520
1519 1521 def _log_audit_action(self, action, action_data, user, pull_request):
1520 1522 audit_logger.store(
1521 1523 action=action,
1522 1524 action_data=action_data,
1523 1525 user=user,
1524 1526 repo=pull_request.target_repo)
1525 1527
1526 1528 def get_reviewer_functions(self):
1527 1529 """
1528 1530 Fetches functions for validation and fetching default reviewers.
1529 1531 If available we use the EE package, else we fallback to CE
1530 1532 package functions
1531 1533 """
1532 1534 try:
1533 1535 from rc_reviewers.utils import get_default_reviewers_data
1534 1536 from rc_reviewers.utils import validate_default_reviewers
1535 1537 except ImportError:
1536 1538 from rhodecode.apps.repository.utils import get_default_reviewers_data
1537 1539 from rhodecode.apps.repository.utils import validate_default_reviewers
1538 1540
1539 1541 return get_default_reviewers_data, validate_default_reviewers
1540 1542
1541 1543
1542 1544 class MergeCheck(object):
1543 1545 """
1544 1546 Perform Merge Checks and returns a check object which stores information
1545 1547 about merge errors, and merge conditions
1546 1548 """
1547 1549 TODO_CHECK = 'todo'
1548 1550 PERM_CHECK = 'perm'
1549 1551 REVIEW_CHECK = 'review'
1550 1552 MERGE_CHECK = 'merge'
1551 1553
1552 1554 def __init__(self):
1553 1555 self.review_status = None
1554 1556 self.merge_possible = None
1555 1557 self.merge_msg = ''
1556 1558 self.failed = None
1557 1559 self.errors = []
1558 1560 self.error_details = OrderedDict()
1559 1561
1560 1562 def push_error(self, error_type, message, error_key, details):
1561 1563 self.failed = True
1562 1564 self.errors.append([error_type, message])
1563 1565 self.error_details[error_key] = dict(
1564 1566 details=details,
1565 1567 error_type=error_type,
1566 1568 message=message
1567 1569 )
1568 1570
1569 1571 @classmethod
1570 1572 def validate(cls, pull_request, auth_user, translator, fail_early=False,
1571 1573 force_shadow_repo_refresh=False):
1572 1574 _ = translator
1573 1575 merge_check = cls()
1574 1576
1575 1577 # permissions to merge
1576 1578 user_allowed_to_merge = PullRequestModel().check_user_merge(
1577 1579 pull_request, auth_user)
1578 1580 if not user_allowed_to_merge:
1579 1581 log.debug("MergeCheck: cannot merge, approval is pending.")
1580 1582
1581 1583 msg = _('User `{}` not allowed to perform merge.').format(auth_user.username)
1582 1584 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1583 1585 if fail_early:
1584 1586 return merge_check
1585 1587
1586 1588 # permission to merge into the target branch
1587 1589 target_commit_id = pull_request.target_ref_parts.commit_id
1588 1590 if pull_request.target_ref_parts.type == 'branch':
1589 1591 branch_name = pull_request.target_ref_parts.name
1590 1592 else:
1591 1593 # for mercurial we can always figure out the branch from the commit
1592 1594 # in case of bookmark
1593 1595 target_commit = pull_request.target_repo.get_commit(target_commit_id)
1594 1596 branch_name = target_commit.branch
1595 1597
1596 1598 rule, branch_perm = auth_user.get_rule_and_branch_permission(
1597 1599 pull_request.target_repo.repo_name, branch_name)
1598 1600 if branch_perm and branch_perm == 'branch.none':
1599 1601 msg = _('Target branch `{}` changes rejected by rule {}.').format(
1600 1602 branch_name, rule)
1601 1603 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1602 1604 if fail_early:
1603 1605 return merge_check
1604 1606
1605 1607 # review status, must be always present
1606 1608 review_status = pull_request.calculated_review_status()
1607 1609 merge_check.review_status = review_status
1608 1610
1609 1611 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1610 1612 if not status_approved:
1611 1613 log.debug("MergeCheck: cannot merge, approval is pending.")
1612 1614
1613 1615 msg = _('Pull request reviewer approval is pending.')
1614 1616
1615 1617 merge_check.push_error(
1616 1618 'warning', msg, cls.REVIEW_CHECK, review_status)
1617 1619
1618 1620 if fail_early:
1619 1621 return merge_check
1620 1622
1621 1623 # left over TODOs
1622 1624 todos = CommentsModel().get_unresolved_todos(pull_request)
1623 1625 if todos:
1624 1626 log.debug("MergeCheck: cannot merge, {} "
1625 1627 "unresolved todos left.".format(len(todos)))
1626 1628
1627 1629 if len(todos) == 1:
1628 1630 msg = _('Cannot merge, {} TODO still not resolved.').format(
1629 1631 len(todos))
1630 1632 else:
1631 1633 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1632 1634 len(todos))
1633 1635
1634 1636 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1635 1637
1636 1638 if fail_early:
1637 1639 return merge_check
1638 1640
1639 1641 # merge possible, here is the filesystem simulation + shadow repo
1640 1642 merge_status, msg = PullRequestModel().merge_status(
1641 1643 pull_request, translator=translator,
1642 1644 force_shadow_repo_refresh=force_shadow_repo_refresh)
1643 1645 merge_check.merge_possible = merge_status
1644 1646 merge_check.merge_msg = msg
1645 1647 if not merge_status:
1646 1648 log.debug(
1647 1649 "MergeCheck: cannot merge, pull request merge not possible.")
1648 1650 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1649 1651
1650 1652 if fail_early:
1651 1653 return merge_check
1652 1654
1653 1655 log.debug('MergeCheck: is failed: %s', merge_check.failed)
1654 1656 return merge_check
1655 1657
1656 1658 @classmethod
1657 1659 def get_merge_conditions(cls, pull_request, translator):
1658 1660 _ = translator
1659 1661 merge_details = {}
1660 1662
1661 1663 model = PullRequestModel()
1662 1664 use_rebase = model._use_rebase_for_merging(pull_request)
1663 1665
1664 1666 if use_rebase:
1665 1667 merge_details['merge_strategy'] = dict(
1666 1668 details={},
1667 1669 message=_('Merge strategy: rebase')
1668 1670 )
1669 1671 else:
1670 1672 merge_details['merge_strategy'] = dict(
1671 1673 details={},
1672 1674 message=_('Merge strategy: explicit merge commit')
1673 1675 )
1674 1676
1675 1677 close_branch = model._close_branch_before_merging(pull_request)
1676 1678 if close_branch:
1677 1679 repo_type = pull_request.target_repo.repo_type
1678 1680 if repo_type == 'hg':
1679 1681 close_msg = _('Source branch will be closed after merge.')
1680 1682 elif repo_type == 'git':
1681 1683 close_msg = _('Source branch will be deleted after merge.')
1682 1684
1683 1685 merge_details['close_branch'] = dict(
1684 1686 details={},
1685 1687 message=close_msg
1686 1688 )
1687 1689
1688 1690 return merge_details
1689 1691
1690 1692 ChangeTuple = collections.namedtuple(
1691 1693 'ChangeTuple', ['added', 'common', 'removed', 'total'])
1692 1694
1693 1695 FileChangeTuple = collections.namedtuple(
1694 1696 'FileChangeTuple', ['added', 'modified', 'removed'])
@@ -1,1023 +1,1025 b''
1 1 <%namespace name="commentblock" file="/changeset/changeset_file_comment.mako"/>
2 2
3 3 <%def name="diff_line_anchor(commit, filename, line, type)"><%
4 4 return '%s_%s_%i' % (h.md5_safe(commit+filename), type, line)
5 5 %></%def>
6 6
7 7 <%def name="action_class(action)">
8 8 <%
9 9 return {
10 10 '-': 'cb-deletion',
11 11 '+': 'cb-addition',
12 12 ' ': 'cb-context',
13 13 }.get(action, 'cb-empty')
14 14 %>
15 15 </%def>
16 16
17 17 <%def name="op_class(op_id)">
18 18 <%
19 19 return {
20 20 DEL_FILENODE: 'deletion', # file deleted
21 21 BIN_FILENODE: 'warning' # binary diff hidden
22 22 }.get(op_id, 'addition')
23 23 %>
24 24 </%def>
25 25
26 26
27 27
28 28 <%def name="render_diffset(diffset, commit=None,
29 29
30 30 # collapse all file diff entries when there are more than this amount of files in the diff
31 31 collapse_when_files_over=20,
32 32
33 33 # collapse lines in the diff when more than this amount of lines changed in the file diff
34 34 lines_changed_limit=500,
35 35
36 36 # add a ruler at to the output
37 37 ruler_at_chars=0,
38 38
39 39 # show inline comments
40 40 use_comments=False,
41 41
42 42 # disable new comments
43 43 disable_new_comments=False,
44 44
45 45 # special file-comments that were deleted in previous versions
46 46 # it's used for showing outdated comments for deleted files in a PR
47 47 deleted_files_comments=None,
48 48
49 49 # for cache purpose
50 50 inline_comments=None,
51 51
52 52 )">
53 53 %if use_comments:
54 54 <div id="cb-comments-inline-container-template" class="js-template">
55 55 ${inline_comments_container([], inline_comments)}
56 56 </div>
57 57 <div class="js-template" id="cb-comment-inline-form-template">
58 58 <div class="comment-inline-form ac">
59 59
60 60 %if c.rhodecode_user.username != h.DEFAULT_USER:
61 61 ## render template for inline comments
62 62 ${commentblock.comment_form(form_type='inline')}
63 63 %else:
64 64 ${h.form('', class_='inline-form comment-form-login', method='get')}
65 65 <div class="pull-left">
66 66 <div class="comment-help pull-right">
67 67 ${_('You need to be logged in to leave comments.')} <a href="${h.route_path('login', _query={'came_from': h.current_route_path(request)})}">${_('Login now')}</a>
68 68 </div>
69 69 </div>
70 70 <div class="comment-button pull-right">
71 71 <button type="button" class="cb-comment-cancel" onclick="return Rhodecode.comments.cancelComment(this);">
72 72 ${_('Cancel')}
73 73 </button>
74 74 </div>
75 75 <div class="clearfix"></div>
76 76 ${h.end_form()}
77 77 %endif
78 78 </div>
79 79 </div>
80 80
81 81 %endif
82 82 <%
83 83 collapse_all = len(diffset.files) > collapse_when_files_over
84 84 %>
85 85
86 86 %if c.user_session_attrs["diffmode"] == 'sideside':
87 87 <style>
88 88 .wrapper {
89 89 max-width: 1600px !important;
90 90 }
91 91 </style>
92 92 %endif
93 93
94 94 %if ruler_at_chars:
95 95 <style>
96 96 .diff table.cb .cb-content:after {
97 97 content: "";
98 98 border-left: 1px solid blue;
99 99 position: absolute;
100 100 top: 0;
101 101 height: 18px;
102 102 opacity: .2;
103 103 z-index: 10;
104 104 //## +5 to account for diff action (+/-)
105 105 left: ${ruler_at_chars + 5}ch;
106 106 </style>
107 107 %endif
108 108
109 109 <div class="diffset ${disable_new_comments and 'diffset-comments-disabled'}">
110 110 <div class="diffset-heading ${diffset.limited_diff and 'diffset-heading-warning' or ''}">
111 111 %if commit:
112 112 <div class="pull-right">
113 113 <a class="btn tooltip" title="${h.tooltip(_('Browse Files at revision {}').format(commit.raw_id))}" href="${h.route_path('repo_files',repo_name=diffset.repo_name, commit_id=commit.raw_id, f_path='')}">
114 114 ${_('Browse Files')}
115 115 </a>
116 116 </div>
117 117 %endif
118 118 <h2 class="clearinner">
119 119 ## invidual commit
120 120 % if commit:
121 121 <a class="tooltip revision" title="${h.tooltip(commit.message)}" href="${h.route_path('repo_commit',repo_name=diffset.repo_name,commit_id=commit.raw_id)}">${('r%s:%s' % (commit.idx,h.short_id(commit.raw_id)))}</a> -
122 122 ${h.age_component(commit.date)}
123 123 % if diffset.limited_diff:
124 124 - ${_('The requested commit is too big and content was truncated.')}
125 125 ${_ungettext('%(num)s file changed.', '%(num)s files changed.', diffset.changed_files) % {'num': diffset.changed_files}}
126 126 <a href="${h.current_route_path(request, fulldiff=1)}" onclick="return confirm('${_("Showing a big diff might take some time and resources, continue?")}')">${_('Show full diff')}</a>
127 127 % elif hasattr(c, 'commit_ranges') and len(c.commit_ranges) > 1:
128 128 ## compare diff, has no file-selector and we want to show stats anyway
129 129 ${_ungettext('{num} file changed: {linesadd} inserted, ''{linesdel} deleted',
130 130 '{num} files changed: {linesadd} inserted, {linesdel} deleted', diffset.changed_files) \
131 131 .format(num=diffset.changed_files, linesadd=diffset.lines_added, linesdel=diffset.lines_deleted)}
132 132 % endif
133 133 % else:
134 134 ## pull requests/compare
135 135 ${_('File Changes')}
136 136 % endif
137 137
138 138 </h2>
139 139 </div>
140 140
141 141 %if diffset.has_hidden_changes:
142 142 <p class="empty_data">${_('Some changes may be hidden')}</p>
143 143 %elif not diffset.files:
144 144 <p class="empty_data">${_('No files')}</p>
145 145 %endif
146 146
147 147 <div class="filediffs">
148 148
149 149 ## initial value could be marked as False later on
150 150 <% over_lines_changed_limit = False %>
151 151 %for i, filediff in enumerate(diffset.files):
152 152
153 153 <%
154 154 lines_changed = filediff.patch['stats']['added'] + filediff.patch['stats']['deleted']
155 155 over_lines_changed_limit = lines_changed > lines_changed_limit
156 156 %>
157 157 ## anchor with support of sticky header
158 158 <div class="anchor" id="a_${h.FID(filediff.raw_id, filediff.patch['filename'])}"></div>
159 159
160 160 <input ${(collapse_all and 'checked' or '')} class="filediff-collapse-state" id="filediff-collapse-${id(filediff)}" type="checkbox" onchange="updateSticky();">
161 161 <div
162 162 class="filediff"
163 163 data-f-path="${filediff.patch['filename']}"
164 164 data-anchor-id="${h.FID(filediff.raw_id, filediff.patch['filename'])}"
165 165 >
166 166 <label for="filediff-collapse-${id(filediff)}" class="filediff-heading">
167 167 <div class="filediff-collapse-indicator"></div>
168 168 ${diff_ops(filediff)}
169 169 </label>
170 170
171 171 ${diff_menu(filediff, use_comments=use_comments)}
172 172 <table data-f-path="${filediff.patch['filename']}" data-anchor-id="${h.FID(filediff.raw_id, filediff.patch['filename'])}" class="code-visible-block cb cb-diff-${c.user_session_attrs["diffmode"]} code-highlight ${(over_lines_changed_limit and 'cb-collapsed' or '')}">
173 173
174 174 ## new/deleted/empty content case
175 175 % if not filediff.hunks:
176 176 ## Comment container, on "fakes" hunk that contains all data to render comments
177 177 ${render_hunk_lines(filediff, c.user_session_attrs["diffmode"], filediff.hunk_ops, use_comments=use_comments, inline_comments=inline_comments)}
178 178 % endif
179 179
180 180 %if filediff.limited_diff:
181 181 <tr class="cb-warning cb-collapser">
182 182 <td class="cb-text" ${(c.user_session_attrs["diffmode"] == 'unified' and 'colspan=4' or 'colspan=6')}>
183 183 ${_('The requested commit is too big and content was truncated.')} <a href="${h.current_route_path(request, fulldiff=1)}" onclick="return confirm('${_("Showing a big diff might take some time and resources, continue?")}')">${_('Show full diff')}</a>
184 184 </td>
185 185 </tr>
186 186 %else:
187 187 %if over_lines_changed_limit:
188 188 <tr class="cb-warning cb-collapser">
189 189 <td class="cb-text" ${(c.user_session_attrs["diffmode"] == 'unified' and 'colspan=4' or 'colspan=6')}>
190 190 ${_('This diff has been collapsed as it changes many lines, (%i lines changed)' % lines_changed)}
191 191 <a href="#" class="cb-expand"
192 192 onclick="$(this).closest('table').removeClass('cb-collapsed'); updateSticky(); return false;">${_('Show them')}
193 193 </a>
194 194 <a href="#" class="cb-collapse"
195 195 onclick="$(this).closest('table').addClass('cb-collapsed'); updateSticky(); return false;">${_('Hide them')}
196 196 </a>
197 197 </td>
198 198 </tr>
199 199 %endif
200 200 %endif
201 201
202 202 % for hunk in filediff.hunks:
203 203 <tr class="cb-hunk">
204 204 <td ${(c.user_session_attrs["diffmode"] == 'unified' and 'colspan=3' or '')}>
205 205 ## TODO: dan: add ajax loading of more context here
206 206 ## <a href="#">
207 207 <i class="icon-more"></i>
208 208 ## </a>
209 209 </td>
210 210 <td ${(c.user_session_attrs["diffmode"] == 'sideside' and 'colspan=5' or '')}>
211 211 @@
212 212 -${hunk.source_start},${hunk.source_length}
213 213 +${hunk.target_start},${hunk.target_length}
214 214 ${hunk.section_header}
215 215 </td>
216 216 </tr>
217 217 ${render_hunk_lines(filediff, c.user_session_attrs["diffmode"], hunk, use_comments=use_comments, inline_comments=inline_comments)}
218 218 % endfor
219 219
220 220 <% unmatched_comments = (inline_comments or {}).get(filediff.patch['filename'], {}) %>
221 221
222 222 ## outdated comments that do not fit into currently displayed lines
223 223 % for lineno, comments in unmatched_comments.items():
224 224
225 225 %if c.user_session_attrs["diffmode"] == 'unified':
226 226 % if loop.index == 0:
227 227 <tr class="cb-hunk">
228 228 <td colspan="3"></td>
229 229 <td>
230 230 <div>
231 231 ${_('Unmatched inline comments below')}
232 232 </div>
233 233 </td>
234 234 </tr>
235 235 % endif
236 236 <tr class="cb-line">
237 237 <td class="cb-data cb-context"></td>
238 238 <td class="cb-lineno cb-context"></td>
239 239 <td class="cb-lineno cb-context"></td>
240 240 <td class="cb-content cb-context">
241 241 ${inline_comments_container(comments, inline_comments)}
242 242 </td>
243 243 </tr>
244 244 %elif c.user_session_attrs["diffmode"] == 'sideside':
245 245 % if loop.index == 0:
246 246 <tr class="cb-comment-info">
247 247 <td colspan="2"></td>
248 248 <td class="cb-line">
249 249 <div>
250 250 ${_('Unmatched inline comments below')}
251 251 </div>
252 252 </td>
253 253 <td colspan="2"></td>
254 254 <td class="cb-line">
255 255 <div>
256 256 ${_('Unmatched comments below')}
257 257 </div>
258 258 </td>
259 259 </tr>
260 260 % endif
261 261 <tr class="cb-line">
262 262 <td class="cb-data cb-context"></td>
263 263 <td class="cb-lineno cb-context"></td>
264 264 <td class="cb-content cb-context">
265 265 % if lineno.startswith('o'):
266 266 ${inline_comments_container(comments, inline_comments)}
267 267 % endif
268 268 </td>
269 269
270 270 <td class="cb-data cb-context"></td>
271 271 <td class="cb-lineno cb-context"></td>
272 272 <td class="cb-content cb-context">
273 273 % if lineno.startswith('n'):
274 274 ${inline_comments_container(comments, inline_comments)}
275 275 % endif
276 276 </td>
277 277 </tr>
278 278 %endif
279 279
280 280 % endfor
281 281
282 282 </table>
283 283 </div>
284 284 %endfor
285 285
286 286 ## outdated comments that are made for a file that has been deleted
287 287 % for filename, comments_dict in (deleted_files_comments or {}).items():
288 288 <%
289 289 display_state = 'display: none'
290 290 open_comments_in_file = [x for x in comments_dict['comments'] if x.outdated is False]
291 291 if open_comments_in_file:
292 292 display_state = ''
293 293 %>
294 294 <div class="filediffs filediff-outdated" style="${display_state}">
295 295 <input ${(collapse_all and 'checked' or '')} class="filediff-collapse-state" id="filediff-collapse-${id(filename)}" type="checkbox" onchange="updateSticky();">
296 296 <div class="filediff" data-f-path="${filename}" id="a_${h.FID(filediff.raw_id, filename)}">
297 297 <label for="filediff-collapse-${id(filename)}" class="filediff-heading">
298 298 <div class="filediff-collapse-indicator"></div>
299 299 <span class="pill">
300 300 ## file was deleted
301 301 <strong>${filename}</strong>
302 302 </span>
303 303 <span class="pill-group" style="float: left">
304 304 ## file op, doesn't need translation
305 305 <span class="pill" op="removed">removed in this version</span>
306 306 </span>
307 307 <a class="pill filediff-anchor" href="#a_${h.FID(filediff.raw_id, filename)}">ΒΆ</a>
308 308 <span class="pill-group" style="float: right">
309 309 <span class="pill" op="deleted">-${comments_dict['stats']}</span>
310 310 </span>
311 311 </label>
312 312
313 313 <table class="cb cb-diff-${c.user_session_attrs["diffmode"]} code-highlight ${over_lines_changed_limit and 'cb-collapsed' or ''}">
314 314 <tr>
315 315 % if c.user_session_attrs["diffmode"] == 'unified':
316 316 <td></td>
317 317 %endif
318 318
319 319 <td></td>
320 320 <td class="cb-text cb-${op_class(BIN_FILENODE)}" ${(c.user_session_attrs["diffmode"] == 'unified' and 'colspan=4' or 'colspan=5')}>
321 321 ${_('File was deleted in this version. There are still outdated/unresolved comments attached to it.')}
322 322 </td>
323 323 </tr>
324 324 %if c.user_session_attrs["diffmode"] == 'unified':
325 325 <tr class="cb-line">
326 326 <td class="cb-data cb-context"></td>
327 327 <td class="cb-lineno cb-context"></td>
328 328 <td class="cb-lineno cb-context"></td>
329 329 <td class="cb-content cb-context">
330 330 ${inline_comments_container(comments_dict['comments'], inline_comments)}
331 331 </td>
332 332 </tr>
333 333 %elif c.user_session_attrs["diffmode"] == 'sideside':
334 334 <tr class="cb-line">
335 335 <td class="cb-data cb-context"></td>
336 336 <td class="cb-lineno cb-context"></td>
337 337 <td class="cb-content cb-context"></td>
338 338
339 339 <td class="cb-data cb-context"></td>
340 340 <td class="cb-lineno cb-context"></td>
341 341 <td class="cb-content cb-context">
342 342 ${inline_comments_container(comments_dict['comments'], inline_comments)}
343 343 </td>
344 344 </tr>
345 345 %endif
346 346 </table>
347 347 </div>
348 348 </div>
349 349 % endfor
350 350
351 351 </div>
352 352 </div>
353 353 </%def>
354 354
355 355 <%def name="diff_ops(filediff)">
356 356 <%
357 357 from rhodecode.lib.diffs import NEW_FILENODE, DEL_FILENODE, \
358 358 MOD_FILENODE, RENAMED_FILENODE, CHMOD_FILENODE, BIN_FILENODE, COPIED_FILENODE
359 359 %>
360 360 <span class="pill">
361 361 %if filediff.source_file_path and filediff.target_file_path:
362 362 %if filediff.source_file_path != filediff.target_file_path:
363 363 ## file was renamed, or copied
364 364 %if RENAMED_FILENODE in filediff.patch['stats']['ops']:
365 365 <strong>${filediff.target_file_path}</strong> β¬… <del>${filediff.source_file_path}</del>
366 366 <% final_path = filediff.target_file_path %>
367 367 %elif COPIED_FILENODE in filediff.patch['stats']['ops']:
368 368 <strong>${filediff.target_file_path}</strong> β¬… ${filediff.source_file_path}
369 369 <% final_path = filediff.target_file_path %>
370 370 %endif
371 371 %else:
372 372 ## file was modified
373 373 <strong>${filediff.source_file_path}</strong>
374 374 <% final_path = filediff.source_file_path %>
375 375 %endif
376 376 %else:
377 377 %if filediff.source_file_path:
378 378 ## file was deleted
379 379 <strong>${filediff.source_file_path}</strong>
380 380 <% final_path = filediff.source_file_path %>
381 381 %else:
382 382 ## file was added
383 383 <strong>${filediff.target_file_path}</strong>
384 384 <% final_path = filediff.target_file_path %>
385 385 %endif
386 386 %endif
387 387 <i style="color: #aaa" class="tooltip icon-clipboard clipboard-action" data-clipboard-text="${final_path}" title="${_('Copy the full path')}" onclick="return false;"></i>
388 388 </span>
389 389 ## anchor link
390 390 <a class="pill filediff-anchor" href="#a_${h.FID(filediff.raw_id, filediff.patch['filename'])}">ΒΆ</a>
391 391
392 392 <span class="pill-group" style="float: right">
393 393
394 394 ## ops pills
395 395 %if filediff.limited_diff:
396 396 <span class="pill tooltip" op="limited" title="The stats for this diff are not complete">limited diff</span>
397 397 %endif
398 398
399 399 %if NEW_FILENODE in filediff.patch['stats']['ops']:
400 400 <span class="pill" op="created">created</span>
401 401 %if filediff['target_mode'].startswith('120'):
402 402 <span class="pill" op="symlink">symlink</span>
403 403 %else:
404 404 <span class="pill" op="mode">${nice_mode(filediff['target_mode'])}</span>
405 405 %endif
406 406 %endif
407 407
408 408 %if RENAMED_FILENODE in filediff.patch['stats']['ops']:
409 409 <span class="pill" op="renamed">renamed</span>
410 410 %endif
411 411
412 412 %if COPIED_FILENODE in filediff.patch['stats']['ops']:
413 413 <span class="pill" op="copied">copied</span>
414 414 %endif
415 415
416 416 %if DEL_FILENODE in filediff.patch['stats']['ops']:
417 417 <span class="pill" op="removed">removed</span>
418 418 %endif
419 419
420 420 %if CHMOD_FILENODE in filediff.patch['stats']['ops']:
421 421 <span class="pill" op="mode">
422 422 ${nice_mode(filediff['source_mode'])} ➑ ${nice_mode(filediff['target_mode'])}
423 423 </span>
424 424 %endif
425 425
426 426 %if BIN_FILENODE in filediff.patch['stats']['ops']:
427 427 <span class="pill" op="binary">binary</span>
428 428 %if MOD_FILENODE in filediff.patch['stats']['ops']:
429 429 <span class="pill" op="modified">modified</span>
430 430 %endif
431 431 %endif
432 432
433 433 <span class="pill" op="added">${('+' if filediff.patch['stats']['added'] else '')}${filediff.patch['stats']['added']}</span>
434 434 <span class="pill" op="deleted">${((h.safe_int(filediff.patch['stats']['deleted']) or 0) * -1)}</span>
435 435
436 436 </span>
437 437
438 438 </%def>
439 439
440 440 <%def name="nice_mode(filemode)">
441 441 ${(filemode.startswith('100') and filemode[3:] or filemode)}
442 442 </%def>
443 443
444 444 <%def name="diff_menu(filediff, use_comments=False)">
445 445 <div class="filediff-menu">
446 446
447 447 %if filediff.diffset.source_ref:
448 448
449 449 ## FILE BEFORE CHANGES
450 450 %if filediff.operation in ['D', 'M']:
451 451 <a
452 452 class="tooltip"
453 453 href="${h.route_path('repo_files',repo_name=filediff.diffset.target_repo_name,commit_id=filediff.diffset.source_ref,f_path=filediff.source_file_path)}"
454 454 title="${h.tooltip(_('Show file at commit: %(commit_id)s') % {'commit_id': filediff.diffset.source_ref[:12]})}"
455 455 >
456 456 ${_('Show file before')}
457 457 </a> |
458 458 %else:
459 459 <span
460 460 class="tooltip"
461 461 title="${h.tooltip(_('File not present at commit: %(commit_id)s') % {'commit_id': filediff.diffset.source_ref[:12]})}"
462 462 >
463 463 ${_('Show file before')}
464 464 </span> |
465 465 %endif
466 466
467 467 ## FILE AFTER CHANGES
468 468 %if filediff.operation in ['A', 'M']:
469 469 <a
470 470 class="tooltip"
471 471 href="${h.route_path('repo_files',repo_name=filediff.diffset.source_repo_name,commit_id=filediff.diffset.target_ref,f_path=filediff.target_file_path)}"
472 472 title="${h.tooltip(_('Show file at commit: %(commit_id)s') % {'commit_id': filediff.diffset.target_ref[:12]})}"
473 473 >
474 474 ${_('Show file after')}
475 475 </a>
476 476 %else:
477 477 <span
478 478 class="tooltip"
479 479 title="${h.tooltip(_('File not present at commit: %(commit_id)s') % {'commit_id': filediff.diffset.target_ref[:12]})}"
480 480 >
481 481 ${_('Show file after')}
482 482 </span>
483 483 %endif
484 484
485 485 % if use_comments:
486 486 |
487 487 <a href="#" onclick="return Rhodecode.comments.toggleComments(this);">
488 488 <span class="show-comment-button">${_('Show comments')}</span><span class="hide-comment-button">${_('Hide comments')}</span>
489 489 </a>
490 490 % endif
491 491
492 492 %endif
493 493
494 494 </div>
495 495 </%def>
496 496
497 497
498 498 <%def name="inline_comments_container(comments, inline_comments)">
499 499 <div class="inline-comments">
500 500 %for comment in comments:
501 501 ${commentblock.comment_block(comment, inline=True)}
502 502 %endfor
503 503 % if comments and comments[-1].outdated:
504 504 <span class="btn btn-secondary cb-comment-add-button comment-outdated}"
505 505 style="display: none;}">
506 506 ${_('Add another comment')}
507 507 </span>
508 508 % else:
509 509 <span onclick="return Rhodecode.comments.createComment(this)"
510 510 class="btn btn-secondary cb-comment-add-button">
511 511 ${_('Add another comment')}
512 512 </span>
513 513 % endif
514 514
515 515 </div>
516 516 </%def>
517 517
518 518 <%!
519 519 def get_comments_for(diff_type, comments, filename, line_version, line_number):
520 520 if hasattr(filename, 'unicode_path'):
521 521 filename = filename.unicode_path
522 522
523 523 if not isinstance(filename, basestring):
524 524 return None
525 525
526 526 line_key = '{}{}'.format(line_version, line_number) ## e.g o37, n12
527 527
528 528 if comments and filename in comments:
529 529 file_comments = comments[filename]
530 530 if line_key in file_comments:
531 531 data = file_comments.pop(line_key)
532 532 return data
533 533 %>
534 534
535 535 <%def name="render_hunk_lines_sideside(filediff, hunk, use_comments=False, inline_comments=None)">
536 536 %for i, line in enumerate(hunk.sideside):
537 537 <%
538 538 old_line_anchor, new_line_anchor = None, None
539 539
540 540 if line.original.lineno:
541 541 old_line_anchor = diff_line_anchor(filediff.raw_id, hunk.source_file_path, line.original.lineno, 'o')
542 542 if line.modified.lineno:
543 543 new_line_anchor = diff_line_anchor(filediff.raw_id, hunk.target_file_path, line.modified.lineno, 'n')
544 544 %>
545 545
546 546 <tr class="cb-line">
547 547 <td class="cb-data ${action_class(line.original.action)}"
548 548 data-line-no="${line.original.lineno}"
549 549 >
550 550 <div>
551 551
552 552 <% line_old_comments = None %>
553 553 %if line.original.get_comment_args:
554 554 <% line_old_comments = get_comments_for('side-by-side', inline_comments, *line.original.get_comment_args) %>
555 555 %endif
556 556 %if line_old_comments:
557 557 <% has_outdated = any([x.outdated for x in line_old_comments]) %>
558 558 % if has_outdated:
559 559 <i title="${_('comments including outdated')}:${len(line_old_comments)}" class="icon-comment_toggle" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
560 560 % else:
561 561 <i title="${_('comments')}: ${len(line_old_comments)}" class="icon-comment" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
562 562 % endif
563 563 %endif
564 564 </div>
565 565 </td>
566 566 <td class="cb-lineno ${action_class(line.original.action)}"
567 567 data-line-no="${line.original.lineno}"
568 568 %if old_line_anchor:
569 569 id="${old_line_anchor}"
570 570 %endif
571 571 >
572 572 %if line.original.lineno:
573 573 <a name="${old_line_anchor}" href="#${old_line_anchor}">${line.original.lineno}</a>
574 574 %endif
575 575 </td>
576 576 <td class="cb-content ${action_class(line.original.action)}"
577 577 data-line-no="o${line.original.lineno}"
578 578 >
579 579 %if use_comments and line.original.lineno:
580 580 ${render_add_comment_button()}
581 581 %endif
582 582 <span class="cb-code"><span class="cb-action ${action_class(line.original.action)}"></span>${line.original.content or '' | n}</span>
583 583
584 584 %if use_comments and line.original.lineno and line_old_comments:
585 585 ${inline_comments_container(line_old_comments, inline_comments)}
586 586 %endif
587 587
588 588 </td>
589 589 <td class="cb-data ${action_class(line.modified.action)}"
590 590 data-line-no="${line.modified.lineno}"
591 591 >
592 592 <div>
593 593
594 594 %if line.modified.get_comment_args:
595 595 <% line_new_comments = get_comments_for('side-by-side', inline_comments, *line.modified.get_comment_args) %>
596 596 %else:
597 597 <% line_new_comments = None%>
598 598 %endif
599 599 %if line_new_comments:
600 600 <% has_outdated = any([x.outdated for x in line_new_comments]) %>
601 601 % if has_outdated:
602 602 <i title="${_('comments including outdated')}:${len(line_new_comments)}" class="icon-comment_toggle" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
603 603 % else:
604 604 <i title="${_('comments')}: ${len(line_new_comments)}" class="icon-comment" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
605 605 % endif
606 606 %endif
607 607 </div>
608 608 </td>
609 609 <td class="cb-lineno ${action_class(line.modified.action)}"
610 610 data-line-no="${line.modified.lineno}"
611 611 %if new_line_anchor:
612 612 id="${new_line_anchor}"
613 613 %endif
614 614 >
615 615 %if line.modified.lineno:
616 616 <a name="${new_line_anchor}" href="#${new_line_anchor}">${line.modified.lineno}</a>
617 617 %endif
618 618 </td>
619 619 <td class="cb-content ${action_class(line.modified.action)}"
620 620 data-line-no="n${line.modified.lineno}"
621 621 >
622 622 %if use_comments and line.modified.lineno:
623 623 ${render_add_comment_button()}
624 624 %endif
625 625 <span class="cb-code"><span class="cb-action ${action_class(line.modified.action)}"></span>${line.modified.content or '' | n}</span>
626 626 %if use_comments and line.modified.lineno and line_new_comments:
627 627 ${inline_comments_container(line_new_comments, inline_comments)}
628 628 %endif
629 629 </td>
630 630 </tr>
631 631 %endfor
632 632 </%def>
633 633
634 634
635 635 <%def name="render_hunk_lines_unified(filediff, hunk, use_comments=False, inline_comments=None)">
636 636 %for old_line_no, new_line_no, action, content, comments_args in hunk.unified:
637 637
638 638 <%
639 639 old_line_anchor, new_line_anchor = None, None
640 640 if old_line_no:
641 641 old_line_anchor = diff_line_anchor(filediff.raw_id, hunk.source_file_path, old_line_no, 'o')
642 642 if new_line_no:
643 643 new_line_anchor = diff_line_anchor(filediff.raw_id, hunk.target_file_path, new_line_no, 'n')
644 644 %>
645 645 <tr class="cb-line">
646 646 <td class="cb-data ${action_class(action)}">
647 647 <div>
648 648
649 649 %if comments_args:
650 650 <% comments = get_comments_for('unified', inline_comments, *comments_args) %>
651 651 %else:
652 652 <% comments = None %>
653 653 %endif
654 654
655 655 % if comments:
656 656 <% has_outdated = any([x.outdated for x in comments]) %>
657 657 % if has_outdated:
658 658 <i title="${_('comments including outdated')}:${len(comments)}" class="icon-comment_toggle" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
659 659 % else:
660 660 <i title="${_('comments')}: ${len(comments)}" class="icon-comment" onclick="return Rhodecode.comments.toggleLineComments(this)"></i>
661 661 % endif
662 662 % endif
663 663 </div>
664 664 </td>
665 665 <td class="cb-lineno ${action_class(action)}"
666 666 data-line-no="${old_line_no}"
667 667 %if old_line_anchor:
668 668 id="${old_line_anchor}"
669 669 %endif
670 670 >
671 671 %if old_line_anchor:
672 672 <a name="${old_line_anchor}" href="#${old_line_anchor}">${old_line_no}</a>
673 673 %endif
674 674 </td>
675 675 <td class="cb-lineno ${action_class(action)}"
676 676 data-line-no="${new_line_no}"
677 677 %if new_line_anchor:
678 678 id="${new_line_anchor}"
679 679 %endif
680 680 >
681 681 %if new_line_anchor:
682 682 <a name="${new_line_anchor}" href="#${new_line_anchor}">${new_line_no}</a>
683 683 %endif
684 684 </td>
685 685 <td class="cb-content ${action_class(action)}"
686 686 data-line-no="${(new_line_no and 'n' or 'o')}${(new_line_no or old_line_no)}"
687 687 >
688 688 %if use_comments:
689 689 ${render_add_comment_button()}
690 690 %endif
691 691 <span class="cb-code"><span class="cb-action ${action_class(action)}"></span> ${content or '' | n}</span>
692 692 %if use_comments and comments:
693 693 ${inline_comments_container(comments, inline_comments)}
694 694 %endif
695 695 </td>
696 696 </tr>
697 697 %endfor
698 698 </%def>
699 699
700 700
701 701 <%def name="render_hunk_lines(filediff, diff_mode, hunk, use_comments, inline_comments)">
702 702 % if diff_mode == 'unified':
703 703 ${render_hunk_lines_unified(filediff, hunk, use_comments=use_comments, inline_comments=inline_comments)}
704 704 % elif diff_mode == 'sideside':
705 705 ${render_hunk_lines_sideside(filediff, hunk, use_comments=use_comments, inline_comments=inline_comments)}
706 706 % else:
707 707 <tr class="cb-line">
708 708 <td>unknown diff mode</td>
709 709 </tr>
710 710 % endif
711 711 </%def>file changes
712 712
713 713
714 714 <%def name="render_add_comment_button()">
715 715 <button class="btn btn-small btn-primary cb-comment-box-opener" onclick="return Rhodecode.comments.createComment(this)">
716 716 <span><i class="icon-comment"></i></span>
717 717 </button>
718 718 </%def>
719 719
720 720 <%def name="render_diffset_menu(diffset=None, range_diff_on=None)">
721 721
722 722 <div id="diff-file-sticky" class="diffset-menu clearinner">
723 723 ## auto adjustable
724 724 <div class="sidebar__inner">
725 725 <div class="sidebar__bar">
726 726 <div class="pull-right">
727 727 <div class="btn-group">
728 728
729 729 ## DIFF OPTIONS via Select2
730 730 <div class="pull-left">
731 731 ${h.hidden('diff_menu')}
732 732 </div>
733 733
734 734 <a
735 735 class="btn ${(c.user_session_attrs["diffmode"] == 'sideside' and 'btn-primary')} tooltip"
736 736 title="${h.tooltip(_('View side by side'))}"
737 737 href="${h.current_route_path(request, diffmode='sideside')}">
738 738 <span>${_('Side by Side')}</span>
739 739 </a>
740 740
741 741 <a
742 742 class="btn ${(c.user_session_attrs["diffmode"] == 'unified' and 'btn-primary')} tooltip"
743 743 title="${h.tooltip(_('View unified'))}" href="${h.current_route_path(request, diffmode='unified')}">
744 744 <span>${_('Unified')}</span>
745 745 </a>
746 746
747 747 % if range_diff_on is True:
748 748 <a
749 749 title="${_('Turn off: Show the diff as commit range')}"
750 750 class="btn btn-primary"
751 751 href="${h.current_route_path(request, **{"range-diff":"0"})}">
752 752 <span>${_('Range Diff')}</span>
753 753 </a>
754 754 % elif range_diff_on is False:
755 755 <a
756 756 title="${_('Show the diff as commit range')}"
757 757 class="btn"
758 758 href="${h.current_route_path(request, **{"range-diff":"1"})}">
759 759 <span>${_('Range Diff')}</span>
760 760 </a>
761 761 % endif
762 762 </div>
763 763 </div>
764 764 <div class="pull-left">
765 765 <div class="btn-group">
766 766 <div class="pull-left">
767 767 ${h.hidden('file_filter')}
768 768 </div>
769 769 <a
770 770 class="btn"
771 771 href="#"
772 772 onclick="$('input[class=filediff-collapse-state]').prop('checked', false); updateSticky(); return false">${_('Expand All Files')}</a>
773 773 <a
774 774 class="btn"
775 775 href="#"
776 776 onclick="$('input[class=filediff-collapse-state]').prop('checked', true); updateSticky(); return false">${_('Collapse All Files')}</a>
777 777 </div>
778 778 </div>
779 779 </div>
780 780 <div class="fpath-placeholder">
781 781 <i class="icon-file-text"></i>
782 782 <strong class="fpath-placeholder-text">
783 783 Context file:
784 784 </strong>
785 785 </div>
786 786 <div class="sidebar_inner_shadow"></div>
787 787 </div>
788 788 </div>
789 789
790 790 % if diffset:
791 791
792 792 %if diffset.limited_diff:
793 793 <% file_placeholder = _ungettext('%(num)s file changed', '%(num)s files changed', diffset.changed_files) % {'num': diffset.changed_files} %>
794 794 %else:
795 795 <% file_placeholder = _ungettext('%(num)s file changed: %(linesadd)s inserted, ''%(linesdel)s deleted', '%(num)s files changed: %(linesadd)s inserted, %(linesdel)s deleted', diffset.changed_files) % {'num': diffset.changed_files, 'linesadd': diffset.lines_added, 'linesdel': diffset.lines_deleted}%>
796 796 %endif
797 797 ## case on range-diff placeholder needs to be updated
798 798 % if range_diff_on is True:
799 799 <% file_placeholder = _('Disabled on range diff') %>
800 800 % endif
801 801
802 802 <script>
803 803
804 804 var feedFilesOptions = function (query, initialData) {
805 805 var data = {results: []};
806 806 var isQuery = typeof query.term !== 'undefined';
807 807
808 808 var section = _gettext('Changed files');
809 809 var filteredData = [];
810 810
811 811 //filter results
812 812 $.each(initialData.results, function (idx, value) {
813 813
814 814 if (!isQuery || query.term.length === 0 || value.text.toUpperCase().indexOf(query.term.toUpperCase()) >= 0) {
815 815 filteredData.push({
816 816 'id': this.id,
817 817 'text': this.text,
818 818 "ops": this.ops,
819 819 })
820 820 }
821 821
822 822 });
823 823
824 824 data.results = filteredData;
825 825
826 826 query.callback(data);
827 827 };
828 828
829 829 var formatFileResult = function(result, container, query, escapeMarkup) {
830 830 return function(data, escapeMarkup) {
831 831 var container = '<div class="filelist" style="padding-right:100px">{0}</div>';
832 832 var tmpl = '<span style="margin-right:-50px"><strong>{0}</strong></span>'.format(escapeMarkup(data['text']));
833 833 var pill = '<span class="pill-group" style="float: right;margin-right: -100px">' +
834 834 '<span class="pill" op="added">{0}</span>' +
835 835 '<span class="pill" op="deleted">{1}</span>' +
836 836 '</span>'
837 837 ;
838 838 var added = data['ops']['added'];
839 839 if (added === 0) {
840 840 // don't show +0
841 841 added = 0;
842 842 } else {
843 843 added = '+' + added;
844 844 }
845 845
846 846 var deleted = -1*data['ops']['deleted'];
847 847
848 848 tmpl += pill.format(added, deleted);
849 849 return container.format(tmpl);
850 850
851 851 }(result, escapeMarkup);
852 852 };
853 853
854 854 var preloadFileFilterData = {
855 855 results: [
856 856 % for filediff in diffset.files:
857 857 {id:"a_${h.FID(filediff.raw_id, filediff.patch['filename'])}",
858 858 text:"${filediff.patch['filename']}",
859 859 ops:${h.json.dumps(filediff.patch['stats'])|n}}${('' if loop.last else ',')}
860 860 % endfor
861 861 ]
862 862 };
863 863
864 864 $(document).ready(function () {
865 865
866 866 var fileFilter = $("#file_filter").select2({
867 867 'dropdownAutoWidth': true,
868 868 'width': 'auto',
869 869 'placeholder': "${file_placeholder}",
870 870 containerCssClass: "drop-menu",
871 871 dropdownCssClass: "drop-menu-dropdown",
872 872 data: preloadFileFilterData,
873 873 query: function(query) {
874 874 feedFilesOptions(query, preloadFileFilterData);
875 875 },
876 876 formatResult: formatFileResult
877 877 });
878 878
879 879 % if range_diff_on is True:
880 880 fileFilter.select2("enable", false);
881 881 % endif
882 882
883 883 $("#file_filter").on('click', function (e) {
884 884 e.preventDefault();
885 885 var selected = $('#file_filter').select2('data');
886 886 var idSelector = "#"+selected.id;
887 887 window.location.hash = idSelector;
888 888 // expand the container if we quick-select the field
889 889 $(idSelector).next().prop('checked', false);
890 890 updateSticky()
891 891 });
892 892
893 893 var contextPrefix = _gettext('Context file: ');
894 894 ## sticky sidebar
895 895 var sidebarElement = document.getElementById('diff-file-sticky');
896 896 sidebar = new StickySidebar(sidebarElement, {
897 897 topSpacing: 0,
898 898 bottomSpacing: 0,
899 899 innerWrapperSelector: '.sidebar__inner'
900 900 });
901 901 sidebarElement.addEventListener('affixed.static.stickySidebar', function () {
902 902 // reset our file so it's not holding new value
903 903 $('.fpath-placeholder-text').html(contextPrefix)
904 904 });
905 905
906 906 updateSticky = function () {
907 907 sidebar.updateSticky();
908 908 Waypoint.refreshAll();
909 909 };
910 910
911 911 var animateText = $.debounce(100, function(fPath, anchorId) {
912 fPath = Select2.util.escapeMarkup(fPath);
913
912 914 // animate setting the text
913 915 var callback = function () {
914 916 $('.fpath-placeholder-text').animate({'opacity': 1.00}, 200)
915 917 $('.fpath-placeholder-text').html(contextPrefix + '<a href="#a_' + anchorId + '">' + fPath + '</a>')
916 918 };
917 919 $('.fpath-placeholder-text').animate({'opacity': 0.15}, 200, callback);
918 920 });
919 921
920 922 ## dynamic file waypoints
921 923 var setFPathInfo = function(fPath, anchorId){
922 924 animateText(fPath, anchorId)
923 925 };
924 926
925 927 var codeBlock = $('.filediff');
926 928 // forward waypoint
927 929 codeBlock.waypoint(
928 930 function(direction) {
929 931 if (direction === "down"){
930 932 setFPathInfo($(this.element).data('fPath'), $(this.element).data('anchorId'))
931 933 }
932 934 }, {
933 935 offset: 70,
934 936 context: '.fpath-placeholder'
935 937 }
936 938 );
937 939
938 940 // backward waypoint
939 941 codeBlock.waypoint(
940 942 function(direction) {
941 943 if (direction === "up"){
942 944 setFPathInfo($(this.element).data('fPath'), $(this.element).data('anchorId'))
943 945 }
944 946 }, {
945 947 offset: function () {
946 948 return -this.element.clientHeight + 90
947 949 },
948 950 context: '.fpath-placeholder'
949 951 }
950 952 );
951 953
952 954 var preloadDiffMenuData = {
953 955 results: [
954 956 ## Wide diff mode
955 957 {
956 958 id: 1,
957 959 text: _gettext('Toggle Wide Mode diff'),
958 960 action: function () {
959 961 updateSticky();
960 962 Rhodecode.comments.toggleWideMode(this);
961 963 return null;
962 964 },
963 965 url: null,
964 966 },
965 967
966 968 ## Whitespace change
967 969 % if request.GET.get('ignorews', '') == '1':
968 970 {
969 971 id: 2,
970 972 text: _gettext('Show whitespace changes'),
971 973 action: function () {},
972 974 url: "${h.current_route_path(request, ignorews=0)|n}"
973 975 },
974 976 % else:
975 977 {
976 978 id: 2,
977 979 text: _gettext('Hide whitespace changes'),
978 980 action: function () {},
979 981 url: "${h.current_route_path(request, ignorews=1)|n}"
980 982 },
981 983 % endif
982 984
983 985 ## FULL CONTEXT
984 986 % if request.GET.get('fullcontext', '') == '1':
985 987 {
986 988 id: 3,
987 989 text: _gettext('Hide full context diff'),
988 990 action: function () {},
989 991 url: "${h.current_route_path(request, fullcontext=0)|n}"
990 992 },
991 993 % else:
992 994 {
993 995 id: 3,
994 996 text: _gettext('Show full context diff'),
995 997 action: function () {},
996 998 url: "${h.current_route_path(request, fullcontext=1)|n}"
997 999 },
998 1000 % endif
999 1001
1000 1002 ]
1001 1003 };
1002 1004
1003 1005 $("#diff_menu").select2({
1004 1006 minimumResultsForSearch: -1,
1005 1007 containerCssClass: "drop-menu",
1006 1008 dropdownCssClass: "drop-menu-dropdown",
1007 1009 dropdownAutoWidth: true,
1008 1010 data: preloadDiffMenuData,
1009 1011 placeholder: "${_('Diff Options')}",
1010 1012 });
1011 1013 $("#diff_menu").on('select2-selecting', function (e) {
1012 1014 e.choice.action();
1013 1015 if (e.choice.url !== null) {
1014 1016 window.location = e.choice.url
1015 1017 }
1016 1018 });
1017 1019
1018 1020 });
1019 1021
1020 1022 </script>
1021 1023 % endif
1022 1024
1023 1025 </%def>
@@ -1,201 +1,209 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
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 20
21 21 from StringIO import StringIO
22 22
23 23 import pytest
24 24 from mock import patch, Mock
25 25
26 26 from rhodecode.lib.middleware.simplesvn import SimpleSvn, SimpleSvnApp
27 27 from rhodecode.lib.utils import get_rhodecode_base_path
28 28
29 29
30 30 class TestSimpleSvn(object):
31 31 @pytest.fixture(autouse=True)
32 32 def simple_svn(self, baseapp, request_stub):
33 33 base_path = get_rhodecode_base_path()
34 34 self.app = SimpleSvn(
35 35 config={'auth_ret_code': '', 'base_path': base_path},
36 36 registry=request_stub.registry)
37 37
38 38 def test_get_config(self):
39 39 extras = {'foo': 'FOO', 'bar': 'BAR'}
40 40 config = self.app._create_config(extras, repo_name='test-repo')
41 41 assert config == extras
42 42
43 43 @pytest.mark.parametrize(
44 44 'method', ['OPTIONS', 'PROPFIND', 'GET', 'REPORT'])
45 45 def test_get_action_returns_pull(self, method):
46 46 environment = {'REQUEST_METHOD': method}
47 47 action = self.app._get_action(environment)
48 48 assert action == 'pull'
49 49
50 50 @pytest.mark.parametrize(
51 51 'method', [
52 52 'MKACTIVITY', 'PROPPATCH', 'PUT', 'CHECKOUT', 'MKCOL', 'MOVE',
53 53 'COPY', 'DELETE', 'LOCK', 'UNLOCK', 'MERGE'
54 54 ])
55 55 def test_get_action_returns_push(self, method):
56 56 environment = {'REQUEST_METHOD': method}
57 57 action = self.app._get_action(environment)
58 58 assert action == 'push'
59 59
60 60 @pytest.mark.parametrize(
61 61 'path, expected_name', [
62 62 ('/hello-svn', 'hello-svn'),
63 63 ('/hello-svn/', 'hello-svn'),
64 64 ('/group/hello-svn/', 'group/hello-svn'),
65 65 ('/group/hello-svn/!svn/vcc/default', 'group/hello-svn'),
66 66 ])
67 67 def test_get_repository_name(self, path, expected_name):
68 68 environment = {'PATH_INFO': path}
69 69 name = self.app._get_repository_name(environment)
70 70 assert name == expected_name
71 71
72 72 def test_get_repository_name_subfolder(self, backend_svn):
73 73 repo = backend_svn.repo
74 74 environment = {
75 75 'PATH_INFO': '/{}/path/with/subfolders'.format(repo.repo_name)}
76 76 name = self.app._get_repository_name(environment)
77 77 assert name == repo.repo_name
78 78
79 79 def test_create_wsgi_app(self):
80 80 with patch.object(SimpleSvn, '_is_svn_enabled') as mock_method:
81 81 mock_method.return_value = False
82 82 with patch('rhodecode.lib.middleware.simplesvn.DisabledSimpleSvnApp') as (
83 83 wsgi_app_mock):
84 84 config = Mock()
85 85 wsgi_app = self.app._create_wsgi_app(
86 86 repo_path='', repo_name='', config=config)
87 87
88 88 wsgi_app_mock.assert_called_once_with(config)
89 89 assert wsgi_app == wsgi_app_mock()
90 90
91 91 def test_create_wsgi_app_when_enabled(self):
92 92 with patch.object(SimpleSvn, '_is_svn_enabled') as mock_method:
93 93 mock_method.return_value = True
94 94 with patch('rhodecode.lib.middleware.simplesvn.SimpleSvnApp') as (
95 95 wsgi_app_mock):
96 96 config = Mock()
97 97 wsgi_app = self.app._create_wsgi_app(
98 98 repo_path='', repo_name='', config=config)
99 99
100 100 wsgi_app_mock.assert_called_once_with(config)
101 101 assert wsgi_app == wsgi_app_mock()
102 102
103 103
104 104 class TestSimpleSvnApp(object):
105 105 data = '<xml></xml>'
106 106 path = '/group/my-repo'
107 107 wsgi_input = StringIO(data)
108 108 environment = {
109 109 'HTTP_DAV': (
110 110 'http://subversion.tigris.org/xmlns/dav/svn/depth,'
111 111 ' http://subversion.tigris.org/xmlns/dav/svn/mergeinfo'),
112 112 'HTTP_USER_AGENT': 'SVN/1.8.11 (x86_64-linux) serf/1.3.8',
113 113 'REQUEST_METHOD': 'OPTIONS',
114 114 'PATH_INFO': path,
115 115 'wsgi.input': wsgi_input,
116 116 'CONTENT_TYPE': 'text/xml',
117 117 'CONTENT_LENGTH': '130'
118 118 }
119 119
120 120 def setup_method(self, method):
121 121 self.host = 'http://localhost/'
122 122 base_path = get_rhodecode_base_path()
123 123 self.app = SimpleSvnApp(
124 124 config={'subversion_http_server_url': self.host,
125 125 'base_path': base_path})
126 126
127 127 def test_get_request_headers_with_content_type(self):
128 128 expected_headers = {
129 129 'Dav': self.environment['HTTP_DAV'],
130 130 'User-Agent': self.environment['HTTP_USER_AGENT'],
131 131 'Content-Type': self.environment['CONTENT_TYPE'],
132 132 'Content-Length': self.environment['CONTENT_LENGTH']
133 133 }
134 134 headers = self.app._get_request_headers(self.environment)
135 135 assert headers == expected_headers
136 136
137 137 def test_get_request_headers_without_content_type(self):
138 138 environment = self.environment.copy()
139 139 environment.pop('CONTENT_TYPE')
140 140 expected_headers = {
141 141 'Dav': environment['HTTP_DAV'],
142 142 'Content-Length': self.environment['CONTENT_LENGTH'],
143 143 'User-Agent': environment['HTTP_USER_AGENT'],
144 144 }
145 145 request_headers = self.app._get_request_headers(environment)
146 146 assert request_headers == expected_headers
147 147
148 148 def test_get_response_headers(self):
149 149 headers = {
150 150 'Connection': 'keep-alive',
151 151 'Keep-Alive': 'timeout=5, max=100',
152 152 'Transfer-Encoding': 'chunked',
153 153 'Content-Encoding': 'gzip',
154 154 'MS-Author-Via': 'DAV',
155 155 'SVN-Supported-Posts': 'create-txn-with-props'
156 156 }
157 157 expected_headers = [
158 158 ('MS-Author-Via', 'DAV'),
159 159 ('SVN-Supported-Posts', 'create-txn-with-props'),
160 160 ]
161 161 response_headers = self.app._get_response_headers(headers)
162 162 assert sorted(response_headers) == sorted(expected_headers)
163 163
164 def test_get_url(self):
165 url = self.app._get_url(self.path)
166 expected_url = '{}{}'.format(self.host.strip('/'), self.path)
164 @pytest.mark.parametrize('svn_http_url, path_info, expected_url', [
165 ('http://localhost:8200', '/repo_name', 'http://localhost:8200/repo_name'),
166 ('http://localhost:8200///', '/repo_name', 'http://localhost:8200/repo_name'),
167 ('http://localhost:8200', '/group/repo_name', 'http://localhost:8200/group/repo_name'),
168 ('http://localhost:8200/', '/group/repo_name', 'http://localhost:8200/group/repo_name'),
169 ('http://localhost:8200/prefix', '/repo_name', 'http://localhost:8200/prefix/repo_name'),
170 ('http://localhost:8200/prefix', 'repo_name', 'http://localhost:8200/prefix/repo_name'),
171 ('http://localhost:8200/prefix', '/group/repo_name', 'http://localhost:8200/prefix/group/repo_name')
172 ])
173 def test_get_url(self, svn_http_url, path_info, expected_url):
174 url = self.app._get_url(svn_http_url, path_info)
167 175 assert url == expected_url
168 176
169 177 def test_call(self):
170 178 start_response = Mock()
171 179 response_mock = Mock()
172 180 response_mock.headers = {
173 181 'Content-Encoding': 'gzip',
174 182 'MS-Author-Via': 'DAV',
175 183 'SVN-Supported-Posts': 'create-txn-with-props'
176 184 }
177 185 response_mock.status_code = 200
178 186 response_mock.reason = 'OK'
179 187 with patch('rhodecode.lib.middleware.simplesvn.requests.request') as (
180 188 request_mock):
181 189 request_mock.return_value = response_mock
182 190 self.app(self.environment, start_response)
183 191
184 192 expected_url = '{}{}'.format(self.host.strip('/'), self.path)
185 193 expected_request_headers = {
186 194 'Dav': self.environment['HTTP_DAV'],
187 195 'User-Agent': self.environment['HTTP_USER_AGENT'],
188 196 'Content-Type': self.environment['CONTENT_TYPE'],
189 197 'Content-Length': self.environment['CONTENT_LENGTH']
190 198 }
191 199 expected_response_headers = [
192 200 ('SVN-Supported-Posts', 'create-txn-with-props'),
193 201 ('MS-Author-Via', 'DAV'),
194 202 ]
195 203 request_mock.assert_called_once_with(
196 204 self.environment['REQUEST_METHOD'], expected_url,
197 205 data=self.data, headers=expected_request_headers, stream=False)
198 206 response_mock.iter_content.assert_called_once_with(chunk_size=1024)
199 207 args, _ = start_response.call_args
200 208 assert args[0] == '200 OK'
201 209 assert sorted(args[1]) == sorted(expected_response_headers)
General Comments 0
You need to be logged in to leave comments. Login now