##// END OF EJS Templates
pull-requests: validate ref types for pull request so users cannot provide wrongs ones.
marcink -
r3302:2e5cf174 stable
parent child Browse files
Show More
@@ -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.controllers.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,1737 +1,1739 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 MERGE_STATUS_MESSAGES = {
79 79 MergeFailureReason.NONE: lazy_ugettext(
80 80 'This pull request can be automatically merged.'),
81 81 MergeFailureReason.UNKNOWN: lazy_ugettext(
82 82 'This pull request cannot be merged because of an unhandled'
83 83 ' exception.'),
84 84 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
85 85 'This pull request cannot be merged because of merge conflicts.'),
86 86 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
87 87 'This pull request could not be merged because push to target'
88 88 ' failed.'),
89 89 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
90 90 'This pull request cannot be merged because the target is not a'
91 91 ' head.'),
92 92 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
93 93 'This pull request cannot be merged because the source contains'
94 94 ' more branches than the target.'),
95 95 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
96 96 'This pull request cannot be merged because the target has'
97 97 ' multiple heads.'),
98 98 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
99 99 'This pull request cannot be merged because the target repository'
100 100 ' is locked.'),
101 101 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
102 102 'This pull request cannot be merged because the target or the '
103 103 'source reference is missing.'),
104 104 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
105 105 'This pull request cannot be merged because the target '
106 106 'reference is missing.'),
107 107 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
108 108 'This pull request cannot be merged because the source '
109 109 'reference is missing.'),
110 110 MergeFailureReason.SUBREPO_MERGE_FAILED: lazy_ugettext(
111 111 'This pull request cannot be merged because of conflicts related '
112 112 'to sub repositories.'),
113 113 }
114 114
115 115 UPDATE_STATUS_MESSAGES = {
116 116 UpdateFailureReason.NONE: lazy_ugettext(
117 117 'Pull request update successful.'),
118 118 UpdateFailureReason.UNKNOWN: lazy_ugettext(
119 119 'Pull request update failed because of an unknown error.'),
120 120 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
121 121 'No update needed because the source and target have not changed.'),
122 122 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
123 123 'Pull request cannot be updated because the reference type is '
124 124 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
125 125 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
126 126 'This pull request cannot be updated because the target '
127 127 'reference is missing.'),
128 128 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
129 129 'This pull request cannot be updated because the source '
130 130 'reference is missing.'),
131 131 }
132 REF_TYPES = ['bookmark', 'book', 'tag', 'branch']
133 UPDATABLE_REF_TYPES = ['bookmark', 'book', 'branch']
132 134
133 135 def __get_pull_request(self, pull_request):
134 136 return self._get_instance((
135 137 PullRequest, PullRequestVersion), pull_request)
136 138
137 139 def _check_perms(self, perms, pull_request, user, api=False):
138 140 if not api:
139 141 return h.HasRepoPermissionAny(*perms)(
140 142 user=user, repo_name=pull_request.target_repo.repo_name)
141 143 else:
142 144 return h.HasRepoPermissionAnyApi(*perms)(
143 145 user=user, repo_name=pull_request.target_repo.repo_name)
144 146
145 147 def check_user_read(self, pull_request, user, api=False):
146 148 _perms = ('repository.admin', 'repository.write', 'repository.read',)
147 149 return self._check_perms(_perms, pull_request, user, api)
148 150
149 151 def check_user_merge(self, pull_request, user, api=False):
150 152 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
151 153 return self._check_perms(_perms, pull_request, user, api)
152 154
153 155 def check_user_update(self, pull_request, user, api=False):
154 156 owner = user.user_id == pull_request.user_id
155 157 return self.check_user_merge(pull_request, user, api) or owner
156 158
157 159 def check_user_delete(self, pull_request, user):
158 160 owner = user.user_id == pull_request.user_id
159 161 _perms = ('repository.admin',)
160 162 return self._check_perms(_perms, pull_request, user) or owner
161 163
162 164 def check_user_change_status(self, pull_request, user, api=False):
163 165 reviewer = user.user_id in [x.user_id for x in
164 166 pull_request.reviewers]
165 167 return self.check_user_update(pull_request, user, api) or reviewer
166 168
167 169 def check_user_comment(self, pull_request, user):
168 170 owner = user.user_id == pull_request.user_id
169 171 return self.check_user_read(pull_request, user) or owner
170 172
171 173 def get(self, pull_request):
172 174 return self.__get_pull_request(pull_request)
173 175
174 176 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
175 177 opened_by=None, order_by=None,
176 178 order_dir='desc'):
177 179 repo = None
178 180 if repo_name:
179 181 repo = self._get_repo(repo_name)
180 182
181 183 q = PullRequest.query()
182 184
183 185 # source or target
184 186 if repo and source:
185 187 q = q.filter(PullRequest.source_repo == repo)
186 188 elif repo:
187 189 q = q.filter(PullRequest.target_repo == repo)
188 190
189 191 # closed,opened
190 192 if statuses:
191 193 q = q.filter(PullRequest.status.in_(statuses))
192 194
193 195 # opened by filter
194 196 if opened_by:
195 197 q = q.filter(PullRequest.user_id.in_(opened_by))
196 198
197 199 if order_by:
198 200 order_map = {
199 201 'name_raw': PullRequest.pull_request_id,
200 202 'title': PullRequest.title,
201 203 'updated_on_raw': PullRequest.updated_on,
202 204 'target_repo': PullRequest.target_repo_id
203 205 }
204 206 if order_dir == 'asc':
205 207 q = q.order_by(order_map[order_by].asc())
206 208 else:
207 209 q = q.order_by(order_map[order_by].desc())
208 210
209 211 return q
210 212
211 213 def count_all(self, repo_name, source=False, statuses=None,
212 214 opened_by=None):
213 215 """
214 216 Count the number of pull requests for a specific repository.
215 217
216 218 :param repo_name: target or source repo
217 219 :param source: boolean flag to specify if repo_name refers to source
218 220 :param statuses: list of pull request statuses
219 221 :param opened_by: author user of the pull request
220 222 :returns: int number of pull requests
221 223 """
222 224 q = self._prepare_get_all_query(
223 225 repo_name, source=source, statuses=statuses, opened_by=opened_by)
224 226
225 227 return q.count()
226 228
227 229 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
228 230 offset=0, length=None, order_by=None, order_dir='desc'):
229 231 """
230 232 Get all pull requests for a specific repository.
231 233
232 234 :param repo_name: target or source repo
233 235 :param source: boolean flag to specify if repo_name refers to source
234 236 :param statuses: list of pull request statuses
235 237 :param opened_by: author user of the pull request
236 238 :param offset: pagination offset
237 239 :param length: length of returned list
238 240 :param order_by: order of the returned list
239 241 :param order_dir: 'asc' or 'desc' ordering direction
240 242 :returns: list of pull requests
241 243 """
242 244 q = self._prepare_get_all_query(
243 245 repo_name, source=source, statuses=statuses, opened_by=opened_by,
244 246 order_by=order_by, order_dir=order_dir)
245 247
246 248 if length:
247 249 pull_requests = q.limit(length).offset(offset).all()
248 250 else:
249 251 pull_requests = q.all()
250 252
251 253 return pull_requests
252 254
253 255 def count_awaiting_review(self, repo_name, source=False, statuses=None,
254 256 opened_by=None):
255 257 """
256 258 Count the number of pull requests for a specific repository that are
257 259 awaiting review.
258 260
259 261 :param repo_name: target or source repo
260 262 :param source: boolean flag to specify if repo_name refers to source
261 263 :param statuses: list of pull request statuses
262 264 :param opened_by: author user of the pull request
263 265 :returns: int number of pull requests
264 266 """
265 267 pull_requests = self.get_awaiting_review(
266 268 repo_name, source=source, statuses=statuses, opened_by=opened_by)
267 269
268 270 return len(pull_requests)
269 271
270 272 def get_awaiting_review(self, repo_name, source=False, statuses=None,
271 273 opened_by=None, offset=0, length=None,
272 274 order_by=None, order_dir='desc'):
273 275 """
274 276 Get all pull requests for a specific repository that are awaiting
275 277 review.
276 278
277 279 :param repo_name: target or source repo
278 280 :param source: boolean flag to specify if repo_name refers to source
279 281 :param statuses: list of pull request statuses
280 282 :param opened_by: author user of the pull request
281 283 :param offset: pagination offset
282 284 :param length: length of returned list
283 285 :param order_by: order of the returned list
284 286 :param order_dir: 'asc' or 'desc' ordering direction
285 287 :returns: list of pull requests
286 288 """
287 289 pull_requests = self.get_all(
288 290 repo_name, source=source, statuses=statuses, opened_by=opened_by,
289 291 order_by=order_by, order_dir=order_dir)
290 292
291 293 _filtered_pull_requests = []
292 294 for pr in pull_requests:
293 295 status = pr.calculated_review_status()
294 296 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
295 297 ChangesetStatus.STATUS_UNDER_REVIEW]:
296 298 _filtered_pull_requests.append(pr)
297 299 if length:
298 300 return _filtered_pull_requests[offset:offset+length]
299 301 else:
300 302 return _filtered_pull_requests
301 303
302 304 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
303 305 opened_by=None, user_id=None):
304 306 """
305 307 Count the number of pull requests for a specific repository that are
306 308 awaiting review from a specific user.
307 309
308 310 :param repo_name: target or source repo
309 311 :param source: boolean flag to specify if repo_name refers to source
310 312 :param statuses: list of pull request statuses
311 313 :param opened_by: author user of the pull request
312 314 :param user_id: reviewer user of the pull request
313 315 :returns: int number of pull requests
314 316 """
315 317 pull_requests = self.get_awaiting_my_review(
316 318 repo_name, source=source, statuses=statuses, opened_by=opened_by,
317 319 user_id=user_id)
318 320
319 321 return len(pull_requests)
320 322
321 323 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
322 324 opened_by=None, user_id=None, offset=0,
323 325 length=None, order_by=None, order_dir='desc'):
324 326 """
325 327 Get all pull requests for a specific repository that are awaiting
326 328 review from a specific user.
327 329
328 330 :param repo_name: target or source repo
329 331 :param source: boolean flag to specify if repo_name refers to source
330 332 :param statuses: list of pull request statuses
331 333 :param opened_by: author user of the pull request
332 334 :param user_id: reviewer user of the pull request
333 335 :param offset: pagination offset
334 336 :param length: length of returned list
335 337 :param order_by: order of the returned list
336 338 :param order_dir: 'asc' or 'desc' ordering direction
337 339 :returns: list of pull requests
338 340 """
339 341 pull_requests = self.get_all(
340 342 repo_name, source=source, statuses=statuses, opened_by=opened_by,
341 343 order_by=order_by, order_dir=order_dir)
342 344
343 345 _my = PullRequestModel().get_not_reviewed(user_id)
344 346 my_participation = []
345 347 for pr in pull_requests:
346 348 if pr in _my:
347 349 my_participation.append(pr)
348 350 _filtered_pull_requests = my_participation
349 351 if length:
350 352 return _filtered_pull_requests[offset:offset+length]
351 353 else:
352 354 return _filtered_pull_requests
353 355
354 356 def get_not_reviewed(self, user_id):
355 357 return [
356 358 x.pull_request for x in PullRequestReviewers.query().filter(
357 359 PullRequestReviewers.user_id == user_id).all()
358 360 ]
359 361
360 362 def _prepare_participating_query(self, user_id=None, statuses=None,
361 363 order_by=None, order_dir='desc'):
362 364 q = PullRequest.query()
363 365 if user_id:
364 366 reviewers_subquery = Session().query(
365 367 PullRequestReviewers.pull_request_id).filter(
366 368 PullRequestReviewers.user_id == user_id).subquery()
367 369 user_filter = or_(
368 370 PullRequest.user_id == user_id,
369 371 PullRequest.pull_request_id.in_(reviewers_subquery)
370 372 )
371 373 q = PullRequest.query().filter(user_filter)
372 374
373 375 # closed,opened
374 376 if statuses:
375 377 q = q.filter(PullRequest.status.in_(statuses))
376 378
377 379 if order_by:
378 380 order_map = {
379 381 'name_raw': PullRequest.pull_request_id,
380 382 'title': PullRequest.title,
381 383 'updated_on_raw': PullRequest.updated_on,
382 384 'target_repo': PullRequest.target_repo_id
383 385 }
384 386 if order_dir == 'asc':
385 387 q = q.order_by(order_map[order_by].asc())
386 388 else:
387 389 q = q.order_by(order_map[order_by].desc())
388 390
389 391 return q
390 392
391 393 def count_im_participating_in(self, user_id=None, statuses=None):
392 394 q = self._prepare_participating_query(user_id, statuses=statuses)
393 395 return q.count()
394 396
395 397 def get_im_participating_in(
396 398 self, user_id=None, statuses=None, offset=0,
397 399 length=None, order_by=None, order_dir='desc'):
398 400 """
399 401 Get all Pull requests that i'm participating in, or i have opened
400 402 """
401 403
402 404 q = self._prepare_participating_query(
403 405 user_id, statuses=statuses, order_by=order_by,
404 406 order_dir=order_dir)
405 407
406 408 if length:
407 409 pull_requests = q.limit(length).offset(offset).all()
408 410 else:
409 411 pull_requests = q.all()
410 412
411 413 return pull_requests
412 414
413 415 def get_versions(self, pull_request):
414 416 """
415 417 returns version of pull request sorted by ID descending
416 418 """
417 419 return PullRequestVersion.query()\
418 420 .filter(PullRequestVersion.pull_request == pull_request)\
419 421 .order_by(PullRequestVersion.pull_request_version_id.asc())\
420 422 .all()
421 423
422 424 def get_pr_version(self, pull_request_id, version=None):
423 425 at_version = None
424 426
425 427 if version and version == 'latest':
426 428 pull_request_ver = PullRequest.get(pull_request_id)
427 429 pull_request_obj = pull_request_ver
428 430 _org_pull_request_obj = pull_request_obj
429 431 at_version = 'latest'
430 432 elif version:
431 433 pull_request_ver = PullRequestVersion.get_or_404(version)
432 434 pull_request_obj = pull_request_ver
433 435 _org_pull_request_obj = pull_request_ver.pull_request
434 436 at_version = pull_request_ver.pull_request_version_id
435 437 else:
436 438 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
437 439 pull_request_id)
438 440
439 441 pull_request_display_obj = PullRequest.get_pr_display_object(
440 442 pull_request_obj, _org_pull_request_obj)
441 443
442 444 return _org_pull_request_obj, pull_request_obj, \
443 445 pull_request_display_obj, at_version
444 446
445 447 def create(self, created_by, source_repo, source_ref, target_repo,
446 448 target_ref, revisions, reviewers, title, description=None,
447 449 description_renderer=None,
448 450 reviewer_data=None, translator=None, auth_user=None):
449 451 translator = translator or get_current_request().translate
450 452
451 453 created_by_user = self._get_user(created_by)
452 454 auth_user = auth_user or created_by_user.AuthUser()
453 455 source_repo = self._get_repo(source_repo)
454 456 target_repo = self._get_repo(target_repo)
455 457
456 458 pull_request = PullRequest()
457 459 pull_request.source_repo = source_repo
458 460 pull_request.source_ref = source_ref
459 461 pull_request.target_repo = target_repo
460 462 pull_request.target_ref = target_ref
461 463 pull_request.revisions = revisions
462 464 pull_request.title = title
463 465 pull_request.description = description
464 466 pull_request.description_renderer = description_renderer
465 467 pull_request.author = created_by_user
466 468 pull_request.reviewer_data = reviewer_data
467 469
468 470 Session().add(pull_request)
469 471 Session().flush()
470 472
471 473 reviewer_ids = set()
472 474 # members / reviewers
473 475 for reviewer_object in reviewers:
474 476 user_id, reasons, mandatory, rules = reviewer_object
475 477 user = self._get_user(user_id)
476 478
477 479 # skip duplicates
478 480 if user.user_id in reviewer_ids:
479 481 continue
480 482
481 483 reviewer_ids.add(user.user_id)
482 484
483 485 reviewer = PullRequestReviewers()
484 486 reviewer.user = user
485 487 reviewer.pull_request = pull_request
486 488 reviewer.reasons = reasons
487 489 reviewer.mandatory = mandatory
488 490
489 491 # NOTE(marcink): pick only first rule for now
490 492 rule_id = list(rules)[0] if rules else None
491 493 rule = RepoReviewRule.get(rule_id) if rule_id else None
492 494 if rule:
493 495 review_group = rule.user_group_vote_rule(user_id)
494 496 # we check if this particular reviewer is member of a voting group
495 497 if review_group:
496 498 # NOTE(marcink):
497 499 # can be that user is member of more but we pick the first same,
498 500 # same as default reviewers algo
499 501 review_group = review_group[0]
500 502
501 503 rule_data = {
502 504 'rule_name':
503 505 rule.review_rule_name,
504 506 'rule_user_group_entry_id':
505 507 review_group.repo_review_rule_users_group_id,
506 508 'rule_user_group_name':
507 509 review_group.users_group.users_group_name,
508 510 'rule_user_group_members':
509 511 [x.user.username for x in review_group.users_group.members],
510 512 'rule_user_group_members_id':
511 513 [x.user.user_id for x in review_group.users_group.members],
512 514 }
513 515 # e.g {'vote_rule': -1, 'mandatory': True}
514 516 rule_data.update(review_group.rule_data())
515 517
516 518 reviewer.rule_data = rule_data
517 519
518 520 Session().add(reviewer)
519 521 Session().flush()
520 522
521 523 # Set approval status to "Under Review" for all commits which are
522 524 # part of this pull request.
523 525 ChangesetStatusModel().set_status(
524 526 repo=target_repo,
525 527 status=ChangesetStatus.STATUS_UNDER_REVIEW,
526 528 user=created_by_user,
527 529 pull_request=pull_request
528 530 )
529 531 # we commit early at this point. This has to do with a fact
530 532 # that before queries do some row-locking. And because of that
531 533 # we need to commit and finish transation before below validate call
532 534 # that for large repos could be long resulting in long row locks
533 535 Session().commit()
534 536
535 537 # prepare workspace, and run initial merge simulation
536 538 MergeCheck.validate(
537 539 pull_request, auth_user=auth_user, translator=translator)
538 540
539 541 self.notify_reviewers(pull_request, reviewer_ids)
540 542 self._trigger_pull_request_hook(
541 543 pull_request, created_by_user, 'create')
542 544
543 545 creation_data = pull_request.get_api_data(with_merge_state=False)
544 546 self._log_audit_action(
545 547 'repo.pull_request.create', {'data': creation_data},
546 548 auth_user, pull_request)
547 549
548 550 return pull_request
549 551
550 552 def _trigger_pull_request_hook(self, pull_request, user, action):
551 553 pull_request = self.__get_pull_request(pull_request)
552 554 target_scm = pull_request.target_repo.scm_instance()
553 555 if action == 'create':
554 556 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
555 557 elif action == 'merge':
556 558 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
557 559 elif action == 'close':
558 560 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
559 561 elif action == 'review_status_change':
560 562 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
561 563 elif action == 'update':
562 564 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
563 565 else:
564 566 return
565 567
566 568 trigger_hook(
567 569 username=user.username,
568 570 repo_name=pull_request.target_repo.repo_name,
569 571 repo_alias=target_scm.alias,
570 572 pull_request=pull_request)
571 573
572 574 def _get_commit_ids(self, pull_request):
573 575 """
574 576 Return the commit ids of the merged pull request.
575 577
576 578 This method is not dealing correctly yet with the lack of autoupdates
577 579 nor with the implicit target updates.
578 580 For example: if a commit in the source repo is already in the target it
579 581 will be reported anyways.
580 582 """
581 583 merge_rev = pull_request.merge_rev
582 584 if merge_rev is None:
583 585 raise ValueError('This pull request was not merged yet')
584 586
585 587 commit_ids = list(pull_request.revisions)
586 588 if merge_rev not in commit_ids:
587 589 commit_ids.append(merge_rev)
588 590
589 591 return commit_ids
590 592
591 593 def merge_repo(self, pull_request, user, extras):
592 594 log.debug("Merging pull request %s", pull_request.pull_request_id)
593 595 extras['user_agent'] = 'internal-merge'
594 596 merge_state = self._merge_pull_request(pull_request, user, extras)
595 597 if merge_state.executed:
596 598 log.debug(
597 599 "Merge was successful, updating the pull request comments.")
598 600 self._comment_and_close_pr(pull_request, user, merge_state)
599 601
600 602 self._log_audit_action(
601 603 'repo.pull_request.merge',
602 604 {'merge_state': merge_state.__dict__},
603 605 user, pull_request)
604 606
605 607 else:
606 608 log.warn("Merge failed, not updating the pull request.")
607 609 return merge_state
608 610
609 611 def _merge_pull_request(self, pull_request, user, extras, merge_msg=None):
610 612 target_vcs = pull_request.target_repo.scm_instance()
611 613 source_vcs = pull_request.source_repo.scm_instance()
612 614
613 615 message = safe_unicode(merge_msg or vcs_settings.MERGE_MESSAGE_TMPL).format(
614 616 pr_id=pull_request.pull_request_id,
615 617 pr_title=pull_request.title,
616 618 source_repo=source_vcs.name,
617 619 source_ref_name=pull_request.source_ref_parts.name,
618 620 target_repo=target_vcs.name,
619 621 target_ref_name=pull_request.target_ref_parts.name,
620 622 )
621 623
622 624 workspace_id = self._workspace_id(pull_request)
623 625 repo_id = pull_request.target_repo.repo_id
624 626 use_rebase = self._use_rebase_for_merging(pull_request)
625 627 close_branch = self._close_branch_before_merging(pull_request)
626 628
627 629 target_ref = self._refresh_reference(
628 630 pull_request.target_ref_parts, target_vcs)
629 631
630 632 callback_daemon, extras = prepare_callback_daemon(
631 633 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
632 634 host=vcs_settings.HOOKS_HOST,
633 635 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
634 636
635 637 with callback_daemon:
636 638 # TODO: johbo: Implement a clean way to run a config_override
637 639 # for a single call.
638 640 target_vcs.config.set(
639 641 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
640 642
641 643 user_name = user.short_contact
642 644 merge_state = target_vcs.merge(
643 645 repo_id, workspace_id, target_ref, source_vcs,
644 646 pull_request.source_ref_parts,
645 647 user_name=user_name, user_email=user.email,
646 648 message=message, use_rebase=use_rebase,
647 649 close_branch=close_branch)
648 650 return merge_state
649 651
650 652 def _comment_and_close_pr(self, pull_request, user, merge_state, close_msg=None):
651 653 pull_request.merge_rev = merge_state.merge_ref.commit_id
652 654 pull_request.updated_on = datetime.datetime.now()
653 655 close_msg = close_msg or 'Pull request merged and closed'
654 656
655 657 CommentsModel().create(
656 658 text=safe_unicode(close_msg),
657 659 repo=pull_request.target_repo.repo_id,
658 660 user=user.user_id,
659 661 pull_request=pull_request.pull_request_id,
660 662 f_path=None,
661 663 line_no=None,
662 664 closing_pr=True
663 665 )
664 666
665 667 Session().add(pull_request)
666 668 Session().flush()
667 669 # TODO: paris: replace invalidation with less radical solution
668 670 ScmModel().mark_for_invalidation(
669 671 pull_request.target_repo.repo_name)
670 672 self._trigger_pull_request_hook(pull_request, user, 'merge')
671 673
672 674 def has_valid_update_type(self, pull_request):
673 675 source_ref_type = pull_request.source_ref_parts.type
674 return source_ref_type in ['book', 'branch', 'tag']
676 return source_ref_type in self.REF_TYPES
675 677
676 678 def update_commits(self, pull_request):
677 679 """
678 680 Get the updated list of commits for the pull request
679 681 and return the new pull request version and the list
680 682 of commits processed by this update action
681 683 """
682 684 pull_request = self.__get_pull_request(pull_request)
683 685 source_ref_type = pull_request.source_ref_parts.type
684 686 source_ref_name = pull_request.source_ref_parts.name
685 687 source_ref_id = pull_request.source_ref_parts.commit_id
686 688
687 689 target_ref_type = pull_request.target_ref_parts.type
688 690 target_ref_name = pull_request.target_ref_parts.name
689 691 target_ref_id = pull_request.target_ref_parts.commit_id
690 692
691 693 if not self.has_valid_update_type(pull_request):
692 694 log.debug(
693 695 "Skipping update of pull request %s due to ref type: %s",
694 696 pull_request, source_ref_type)
695 697 return UpdateResponse(
696 698 executed=False,
697 699 reason=UpdateFailureReason.WRONG_REF_TYPE,
698 700 old=pull_request, new=None, changes=None,
699 701 source_changed=False, target_changed=False)
700 702
701 703 # source repo
702 704 source_repo = pull_request.source_repo.scm_instance()
703 705 try:
704 706 source_commit = source_repo.get_commit(commit_id=source_ref_name)
705 707 except CommitDoesNotExistError:
706 708 return UpdateResponse(
707 709 executed=False,
708 710 reason=UpdateFailureReason.MISSING_SOURCE_REF,
709 711 old=pull_request, new=None, changes=None,
710 712 source_changed=False, target_changed=False)
711 713
712 714 source_changed = source_ref_id != source_commit.raw_id
713 715
714 716 # target repo
715 717 target_repo = pull_request.target_repo.scm_instance()
716 718 try:
717 719 target_commit = target_repo.get_commit(commit_id=target_ref_name)
718 720 except CommitDoesNotExistError:
719 721 return UpdateResponse(
720 722 executed=False,
721 723 reason=UpdateFailureReason.MISSING_TARGET_REF,
722 724 old=pull_request, new=None, changes=None,
723 725 source_changed=False, target_changed=False)
724 726 target_changed = target_ref_id != target_commit.raw_id
725 727
726 728 if not (source_changed or target_changed):
727 729 log.debug("Nothing changed in pull request %s", pull_request)
728 730 return UpdateResponse(
729 731 executed=False,
730 732 reason=UpdateFailureReason.NO_CHANGE,
731 733 old=pull_request, new=None, changes=None,
732 734 source_changed=target_changed, target_changed=source_changed)
733 735
734 736 change_in_found = 'target repo' if target_changed else 'source repo'
735 737 log.debug('Updating pull request because of change in %s detected',
736 738 change_in_found)
737 739
738 740 # Finally there is a need for an update, in case of source change
739 741 # we create a new version, else just an update
740 742 if source_changed:
741 743 pull_request_version = self._create_version_from_snapshot(pull_request)
742 744 self._link_comments_to_version(pull_request_version)
743 745 else:
744 746 try:
745 747 ver = pull_request.versions[-1]
746 748 except IndexError:
747 749 ver = None
748 750
749 751 pull_request.pull_request_version_id = \
750 752 ver.pull_request_version_id if ver else None
751 753 pull_request_version = pull_request
752 754
753 755 try:
754 if target_ref_type in ('tag', 'branch', 'book'):
756 if target_ref_type in self.REF_TYPES:
755 757 target_commit = target_repo.get_commit(target_ref_name)
756 758 else:
757 759 target_commit = target_repo.get_commit(target_ref_id)
758 760 except CommitDoesNotExistError:
759 761 return UpdateResponse(
760 762 executed=False,
761 763 reason=UpdateFailureReason.MISSING_TARGET_REF,
762 764 old=pull_request, new=None, changes=None,
763 765 source_changed=source_changed, target_changed=target_changed)
764 766
765 767 # re-compute commit ids
766 768 old_commit_ids = pull_request.revisions
767 769 pre_load = ["author", "branch", "date", "message"]
768 770 commit_ranges = target_repo.compare(
769 771 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
770 772 pre_load=pre_load)
771 773
772 774 ancestor = target_repo.get_common_ancestor(
773 775 target_commit.raw_id, source_commit.raw_id, source_repo)
774 776
775 777 pull_request.source_ref = '%s:%s:%s' % (
776 778 source_ref_type, source_ref_name, source_commit.raw_id)
777 779 pull_request.target_ref = '%s:%s:%s' % (
778 780 target_ref_type, target_ref_name, ancestor)
779 781
780 782 pull_request.revisions = [
781 783 commit.raw_id for commit in reversed(commit_ranges)]
782 784 pull_request.updated_on = datetime.datetime.now()
783 785 Session().add(pull_request)
784 786 new_commit_ids = pull_request.revisions
785 787
786 788 old_diff_data, new_diff_data = self._generate_update_diffs(
787 789 pull_request, pull_request_version)
788 790
789 791 # calculate commit and file changes
790 792 changes = self._calculate_commit_id_changes(
791 793 old_commit_ids, new_commit_ids)
792 794 file_changes = self._calculate_file_changes(
793 795 old_diff_data, new_diff_data)
794 796
795 797 # set comments as outdated if DIFFS changed
796 798 CommentsModel().outdate_comments(
797 799 pull_request, old_diff_data=old_diff_data,
798 800 new_diff_data=new_diff_data)
799 801
800 802 commit_changes = (changes.added or changes.removed)
801 803 file_node_changes = (
802 804 file_changes.added or file_changes.modified or file_changes.removed)
803 805 pr_has_changes = commit_changes or file_node_changes
804 806
805 807 # Add an automatic comment to the pull request, in case
806 808 # anything has changed
807 809 if pr_has_changes:
808 810 update_comment = CommentsModel().create(
809 811 text=self._render_update_message(changes, file_changes),
810 812 repo=pull_request.target_repo,
811 813 user=pull_request.author,
812 814 pull_request=pull_request,
813 815 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
814 816
815 817 # Update status to "Under Review" for added commits
816 818 for commit_id in changes.added:
817 819 ChangesetStatusModel().set_status(
818 820 repo=pull_request.source_repo,
819 821 status=ChangesetStatus.STATUS_UNDER_REVIEW,
820 822 comment=update_comment,
821 823 user=pull_request.author,
822 824 pull_request=pull_request,
823 825 revision=commit_id)
824 826
825 827 log.debug(
826 828 'Updated pull request %s, added_ids: %s, common_ids: %s, '
827 829 'removed_ids: %s', pull_request.pull_request_id,
828 830 changes.added, changes.common, changes.removed)
829 831 log.debug(
830 832 'Updated pull request with the following file changes: %s',
831 833 file_changes)
832 834
833 835 log.info(
834 836 "Updated pull request %s from commit %s to commit %s, "
835 837 "stored new version %s of this pull request.",
836 838 pull_request.pull_request_id, source_ref_id,
837 839 pull_request.source_ref_parts.commit_id,
838 840 pull_request_version.pull_request_version_id)
839 841 Session().commit()
840 842 self._trigger_pull_request_hook(
841 843 pull_request, pull_request.author, 'update')
842 844
843 845 return UpdateResponse(
844 846 executed=True, reason=UpdateFailureReason.NONE,
845 847 old=pull_request, new=pull_request_version, changes=changes,
846 848 source_changed=source_changed, target_changed=target_changed)
847 849
848 850 def _create_version_from_snapshot(self, pull_request):
849 851 version = PullRequestVersion()
850 852 version.title = pull_request.title
851 853 version.description = pull_request.description
852 854 version.status = pull_request.status
853 855 version.created_on = datetime.datetime.now()
854 856 version.updated_on = pull_request.updated_on
855 857 version.user_id = pull_request.user_id
856 858 version.source_repo = pull_request.source_repo
857 859 version.source_ref = pull_request.source_ref
858 860 version.target_repo = pull_request.target_repo
859 861 version.target_ref = pull_request.target_ref
860 862
861 863 version._last_merge_source_rev = pull_request._last_merge_source_rev
862 864 version._last_merge_target_rev = pull_request._last_merge_target_rev
863 865 version.last_merge_status = pull_request.last_merge_status
864 866 version.shadow_merge_ref = pull_request.shadow_merge_ref
865 867 version.merge_rev = pull_request.merge_rev
866 868 version.reviewer_data = pull_request.reviewer_data
867 869
868 870 version.revisions = pull_request.revisions
869 871 version.pull_request = pull_request
870 872 Session().add(version)
871 873 Session().flush()
872 874
873 875 return version
874 876
875 877 def _generate_update_diffs(self, pull_request, pull_request_version):
876 878
877 879 diff_context = (
878 880 self.DIFF_CONTEXT +
879 881 CommentsModel.needed_extra_diff_context())
880 882 hide_whitespace_changes = False
881 883 source_repo = pull_request_version.source_repo
882 884 source_ref_id = pull_request_version.source_ref_parts.commit_id
883 885 target_ref_id = pull_request_version.target_ref_parts.commit_id
884 886 old_diff = self._get_diff_from_pr_or_version(
885 887 source_repo, source_ref_id, target_ref_id,
886 888 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
887 889
888 890 source_repo = pull_request.source_repo
889 891 source_ref_id = pull_request.source_ref_parts.commit_id
890 892 target_ref_id = pull_request.target_ref_parts.commit_id
891 893
892 894 new_diff = self._get_diff_from_pr_or_version(
893 895 source_repo, source_ref_id, target_ref_id,
894 896 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
895 897
896 898 old_diff_data = diffs.DiffProcessor(old_diff)
897 899 old_diff_data.prepare()
898 900 new_diff_data = diffs.DiffProcessor(new_diff)
899 901 new_diff_data.prepare()
900 902
901 903 return old_diff_data, new_diff_data
902 904
903 905 def _link_comments_to_version(self, pull_request_version):
904 906 """
905 907 Link all unlinked comments of this pull request to the given version.
906 908
907 909 :param pull_request_version: The `PullRequestVersion` to which
908 910 the comments shall be linked.
909 911
910 912 """
911 913 pull_request = pull_request_version.pull_request
912 914 comments = ChangesetComment.query()\
913 915 .filter(
914 916 # TODO: johbo: Should we query for the repo at all here?
915 917 # Pending decision on how comments of PRs are to be related
916 918 # to either the source repo, the target repo or no repo at all.
917 919 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
918 920 ChangesetComment.pull_request == pull_request,
919 921 ChangesetComment.pull_request_version == None)\
920 922 .order_by(ChangesetComment.comment_id.asc())
921 923
922 924 # TODO: johbo: Find out why this breaks if it is done in a bulk
923 925 # operation.
924 926 for comment in comments:
925 927 comment.pull_request_version_id = (
926 928 pull_request_version.pull_request_version_id)
927 929 Session().add(comment)
928 930
929 931 def _calculate_commit_id_changes(self, old_ids, new_ids):
930 932 added = [x for x in new_ids if x not in old_ids]
931 933 common = [x for x in new_ids if x in old_ids]
932 934 removed = [x for x in old_ids if x not in new_ids]
933 935 total = new_ids
934 936 return ChangeTuple(added, common, removed, total)
935 937
936 938 def _calculate_file_changes(self, old_diff_data, new_diff_data):
937 939
938 940 old_files = OrderedDict()
939 941 for diff_data in old_diff_data.parsed_diff:
940 942 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
941 943
942 944 added_files = []
943 945 modified_files = []
944 946 removed_files = []
945 947 for diff_data in new_diff_data.parsed_diff:
946 948 new_filename = diff_data['filename']
947 949 new_hash = md5_safe(diff_data['raw_diff'])
948 950
949 951 old_hash = old_files.get(new_filename)
950 952 if not old_hash:
951 953 # file is not present in old diff, means it's added
952 954 added_files.append(new_filename)
953 955 else:
954 956 if new_hash != old_hash:
955 957 modified_files.append(new_filename)
956 958 # now remove a file from old, since we have seen it already
957 959 del old_files[new_filename]
958 960
959 961 # removed files is when there are present in old, but not in NEW,
960 962 # since we remove old files that are present in new diff, left-overs
961 963 # if any should be the removed files
962 964 removed_files.extend(old_files.keys())
963 965
964 966 return FileChangeTuple(added_files, modified_files, removed_files)
965 967
966 968 def _render_update_message(self, changes, file_changes):
967 969 """
968 970 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
969 971 so it's always looking the same disregarding on which default
970 972 renderer system is using.
971 973
972 974 :param changes: changes named tuple
973 975 :param file_changes: file changes named tuple
974 976
975 977 """
976 978 new_status = ChangesetStatus.get_status_lbl(
977 979 ChangesetStatus.STATUS_UNDER_REVIEW)
978 980
979 981 changed_files = (
980 982 file_changes.added + file_changes.modified + file_changes.removed)
981 983
982 984 params = {
983 985 'under_review_label': new_status,
984 986 'added_commits': changes.added,
985 987 'removed_commits': changes.removed,
986 988 'changed_files': changed_files,
987 989 'added_files': file_changes.added,
988 990 'modified_files': file_changes.modified,
989 991 'removed_files': file_changes.removed,
990 992 }
991 993 renderer = RstTemplateRenderer()
992 994 return renderer.render('pull_request_update.mako', **params)
993 995
994 996 def edit(self, pull_request, title, description, description_renderer, user):
995 997 pull_request = self.__get_pull_request(pull_request)
996 998 old_data = pull_request.get_api_data(with_merge_state=False)
997 999 if pull_request.is_closed():
998 1000 raise ValueError('This pull request is closed')
999 1001 if title:
1000 1002 pull_request.title = title
1001 1003 pull_request.description = description
1002 1004 pull_request.updated_on = datetime.datetime.now()
1003 1005 pull_request.description_renderer = description_renderer
1004 1006 Session().add(pull_request)
1005 1007 self._log_audit_action(
1006 1008 'repo.pull_request.edit', {'old_data': old_data},
1007 1009 user, pull_request)
1008 1010
1009 1011 def update_reviewers(self, pull_request, reviewer_data, user):
1010 1012 """
1011 1013 Update the reviewers in the pull request
1012 1014
1013 1015 :param pull_request: the pr to update
1014 1016 :param reviewer_data: list of tuples
1015 1017 [(user, ['reason1', 'reason2'], mandatory_flag, [rules])]
1016 1018 """
1017 1019 pull_request = self.__get_pull_request(pull_request)
1018 1020 if pull_request.is_closed():
1019 1021 raise ValueError('This pull request is closed')
1020 1022
1021 1023 reviewers = {}
1022 1024 for user_id, reasons, mandatory, rules in reviewer_data:
1023 1025 if isinstance(user_id, (int, basestring)):
1024 1026 user_id = self._get_user(user_id).user_id
1025 1027 reviewers[user_id] = {
1026 1028 'reasons': reasons, 'mandatory': mandatory}
1027 1029
1028 1030 reviewers_ids = set(reviewers.keys())
1029 1031 current_reviewers = PullRequestReviewers.query()\
1030 1032 .filter(PullRequestReviewers.pull_request ==
1031 1033 pull_request).all()
1032 1034 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
1033 1035
1034 1036 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
1035 1037 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
1036 1038
1037 1039 log.debug("Adding %s reviewers", ids_to_add)
1038 1040 log.debug("Removing %s reviewers", ids_to_remove)
1039 1041 changed = False
1040 1042 for uid in ids_to_add:
1041 1043 changed = True
1042 1044 _usr = self._get_user(uid)
1043 1045 reviewer = PullRequestReviewers()
1044 1046 reviewer.user = _usr
1045 1047 reviewer.pull_request = pull_request
1046 1048 reviewer.reasons = reviewers[uid]['reasons']
1047 1049 # NOTE(marcink): mandatory shouldn't be changed now
1048 1050 # reviewer.mandatory = reviewers[uid]['reasons']
1049 1051 Session().add(reviewer)
1050 1052 self._log_audit_action(
1051 1053 'repo.pull_request.reviewer.add', {'data': reviewer.get_dict()},
1052 1054 user, pull_request)
1053 1055
1054 1056 for uid in ids_to_remove:
1055 1057 changed = True
1056 1058 reviewers = PullRequestReviewers.query()\
1057 1059 .filter(PullRequestReviewers.user_id == uid,
1058 1060 PullRequestReviewers.pull_request == pull_request)\
1059 1061 .all()
1060 1062 # use .all() in case we accidentally added the same person twice
1061 1063 # this CAN happen due to the lack of DB checks
1062 1064 for obj in reviewers:
1063 1065 old_data = obj.get_dict()
1064 1066 Session().delete(obj)
1065 1067 self._log_audit_action(
1066 1068 'repo.pull_request.reviewer.delete',
1067 1069 {'old_data': old_data}, user, pull_request)
1068 1070
1069 1071 if changed:
1070 1072 pull_request.updated_on = datetime.datetime.now()
1071 1073 Session().add(pull_request)
1072 1074
1073 1075 self.notify_reviewers(pull_request, ids_to_add)
1074 1076 return ids_to_add, ids_to_remove
1075 1077
1076 1078 def get_url(self, pull_request, request=None, permalink=False):
1077 1079 if not request:
1078 1080 request = get_current_request()
1079 1081
1080 1082 if permalink:
1081 1083 return request.route_url(
1082 1084 'pull_requests_global',
1083 1085 pull_request_id=pull_request.pull_request_id,)
1084 1086 else:
1085 1087 return request.route_url('pullrequest_show',
1086 1088 repo_name=safe_str(pull_request.target_repo.repo_name),
1087 1089 pull_request_id=pull_request.pull_request_id,)
1088 1090
1089 1091 def get_shadow_clone_url(self, pull_request, request=None):
1090 1092 """
1091 1093 Returns qualified url pointing to the shadow repository. If this pull
1092 1094 request is closed there is no shadow repository and ``None`` will be
1093 1095 returned.
1094 1096 """
1095 1097 if pull_request.is_closed():
1096 1098 return None
1097 1099 else:
1098 1100 pr_url = urllib.unquote(self.get_url(pull_request, request=request))
1099 1101 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1100 1102
1101 1103 def notify_reviewers(self, pull_request, reviewers_ids):
1102 1104 # notification to reviewers
1103 1105 if not reviewers_ids:
1104 1106 return
1105 1107
1106 1108 pull_request_obj = pull_request
1107 1109 # get the current participants of this pull request
1108 1110 recipients = reviewers_ids
1109 1111 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1110 1112
1111 1113 pr_source_repo = pull_request_obj.source_repo
1112 1114 pr_target_repo = pull_request_obj.target_repo
1113 1115
1114 1116 pr_url = h.route_url('pullrequest_show',
1115 1117 repo_name=pr_target_repo.repo_name,
1116 1118 pull_request_id=pull_request_obj.pull_request_id,)
1117 1119
1118 1120 # set some variables for email notification
1119 1121 pr_target_repo_url = h.route_url(
1120 1122 'repo_summary', repo_name=pr_target_repo.repo_name)
1121 1123
1122 1124 pr_source_repo_url = h.route_url(
1123 1125 'repo_summary', repo_name=pr_source_repo.repo_name)
1124 1126
1125 1127 # pull request specifics
1126 1128 pull_request_commits = [
1127 1129 (x.raw_id, x.message)
1128 1130 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1129 1131
1130 1132 kwargs = {
1131 1133 'user': pull_request.author,
1132 1134 'pull_request': pull_request_obj,
1133 1135 'pull_request_commits': pull_request_commits,
1134 1136
1135 1137 'pull_request_target_repo': pr_target_repo,
1136 1138 'pull_request_target_repo_url': pr_target_repo_url,
1137 1139
1138 1140 'pull_request_source_repo': pr_source_repo,
1139 1141 'pull_request_source_repo_url': pr_source_repo_url,
1140 1142
1141 1143 'pull_request_url': pr_url,
1142 1144 }
1143 1145
1144 1146 # pre-generate the subject for notification itself
1145 1147 (subject,
1146 1148 _h, _e, # we don't care about those
1147 1149 body_plaintext) = EmailNotificationModel().render_email(
1148 1150 notification_type, **kwargs)
1149 1151
1150 1152 # create notification objects, and emails
1151 1153 NotificationModel().create(
1152 1154 created_by=pull_request.author,
1153 1155 notification_subject=subject,
1154 1156 notification_body=body_plaintext,
1155 1157 notification_type=notification_type,
1156 1158 recipients=recipients,
1157 1159 email_kwargs=kwargs,
1158 1160 )
1159 1161
1160 1162 def delete(self, pull_request, user):
1161 1163 pull_request = self.__get_pull_request(pull_request)
1162 1164 old_data = pull_request.get_api_data(with_merge_state=False)
1163 1165 self._cleanup_merge_workspace(pull_request)
1164 1166 self._log_audit_action(
1165 1167 'repo.pull_request.delete', {'old_data': old_data},
1166 1168 user, pull_request)
1167 1169 Session().delete(pull_request)
1168 1170
1169 1171 def close_pull_request(self, pull_request, user):
1170 1172 pull_request = self.__get_pull_request(pull_request)
1171 1173 self._cleanup_merge_workspace(pull_request)
1172 1174 pull_request.status = PullRequest.STATUS_CLOSED
1173 1175 pull_request.updated_on = datetime.datetime.now()
1174 1176 Session().add(pull_request)
1175 1177 self._trigger_pull_request_hook(
1176 1178 pull_request, pull_request.author, 'close')
1177 1179
1178 1180 pr_data = pull_request.get_api_data(with_merge_state=False)
1179 1181 self._log_audit_action(
1180 1182 'repo.pull_request.close', {'data': pr_data}, user, pull_request)
1181 1183
1182 1184 def close_pull_request_with_comment(
1183 1185 self, pull_request, user, repo, message=None, auth_user=None):
1184 1186
1185 1187 pull_request_review_status = pull_request.calculated_review_status()
1186 1188
1187 1189 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1188 1190 # approved only if we have voting consent
1189 1191 status = ChangesetStatus.STATUS_APPROVED
1190 1192 else:
1191 1193 status = ChangesetStatus.STATUS_REJECTED
1192 1194 status_lbl = ChangesetStatus.get_status_lbl(status)
1193 1195
1194 1196 default_message = (
1195 1197 'Closing with status change {transition_icon} {status}.'
1196 1198 ).format(transition_icon='>', status=status_lbl)
1197 1199 text = message or default_message
1198 1200
1199 1201 # create a comment, and link it to new status
1200 1202 comment = CommentsModel().create(
1201 1203 text=text,
1202 1204 repo=repo.repo_id,
1203 1205 user=user.user_id,
1204 1206 pull_request=pull_request.pull_request_id,
1205 1207 status_change=status_lbl,
1206 1208 status_change_type=status,
1207 1209 closing_pr=True,
1208 1210 auth_user=auth_user,
1209 1211 )
1210 1212
1211 1213 # calculate old status before we change it
1212 1214 old_calculated_status = pull_request.calculated_review_status()
1213 1215 ChangesetStatusModel().set_status(
1214 1216 repo.repo_id,
1215 1217 status,
1216 1218 user.user_id,
1217 1219 comment=comment,
1218 1220 pull_request=pull_request.pull_request_id
1219 1221 )
1220 1222
1221 1223 Session().flush()
1222 1224 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1223 1225 # we now calculate the status of pull request again, and based on that
1224 1226 # calculation trigger status change. This might happen in cases
1225 1227 # that non-reviewer admin closes a pr, which means his vote doesn't
1226 1228 # change the status, while if he's a reviewer this might change it.
1227 1229 calculated_status = pull_request.calculated_review_status()
1228 1230 if old_calculated_status != calculated_status:
1229 1231 self._trigger_pull_request_hook(
1230 1232 pull_request, user, 'review_status_change')
1231 1233
1232 1234 # finally close the PR
1233 1235 PullRequestModel().close_pull_request(
1234 1236 pull_request.pull_request_id, user)
1235 1237
1236 1238 return comment, status
1237 1239
1238 1240 def merge_status(self, pull_request, translator=None,
1239 1241 force_shadow_repo_refresh=False):
1240 1242 _ = translator or get_current_request().translate
1241 1243
1242 1244 if not self._is_merge_enabled(pull_request):
1243 1245 return False, _('Server-side pull request merging is disabled.')
1244 1246 if pull_request.is_closed():
1245 1247 return False, _('This pull request is closed.')
1246 1248 merge_possible, msg = self._check_repo_requirements(
1247 1249 target=pull_request.target_repo, source=pull_request.source_repo,
1248 1250 translator=_)
1249 1251 if not merge_possible:
1250 1252 return merge_possible, msg
1251 1253
1252 1254 try:
1253 1255 resp = self._try_merge(
1254 1256 pull_request,
1255 1257 force_shadow_repo_refresh=force_shadow_repo_refresh)
1256 1258 log.debug("Merge response: %s", resp)
1257 1259 status = resp.possible, self.merge_status_message(
1258 1260 resp.failure_reason)
1259 1261 except NotImplementedError:
1260 1262 status = False, _('Pull request merging is not supported.')
1261 1263
1262 1264 return status
1263 1265
1264 1266 def _check_repo_requirements(self, target, source, translator):
1265 1267 """
1266 1268 Check if `target` and `source` have compatible requirements.
1267 1269
1268 1270 Currently this is just checking for largefiles.
1269 1271 """
1270 1272 _ = translator
1271 1273 target_has_largefiles = self._has_largefiles(target)
1272 1274 source_has_largefiles = self._has_largefiles(source)
1273 1275 merge_possible = True
1274 1276 message = u''
1275 1277
1276 1278 if target_has_largefiles != source_has_largefiles:
1277 1279 merge_possible = False
1278 1280 if source_has_largefiles:
1279 1281 message = _(
1280 1282 'Target repository large files support is disabled.')
1281 1283 else:
1282 1284 message = _(
1283 1285 'Source repository large files support is disabled.')
1284 1286
1285 1287 return merge_possible, message
1286 1288
1287 1289 def _has_largefiles(self, repo):
1288 1290 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1289 1291 'extensions', 'largefiles')
1290 1292 return largefiles_ui and largefiles_ui[0].active
1291 1293
1292 1294 def _try_merge(self, pull_request, force_shadow_repo_refresh=False):
1293 1295 """
1294 1296 Try to merge the pull request and return the merge status.
1295 1297 """
1296 1298 log.debug(
1297 1299 "Trying out if the pull request %s can be merged. Force_refresh=%s",
1298 1300 pull_request.pull_request_id, force_shadow_repo_refresh)
1299 1301 target_vcs = pull_request.target_repo.scm_instance()
1300 1302
1301 1303 # Refresh the target reference.
1302 1304 try:
1303 1305 target_ref = self._refresh_reference(
1304 1306 pull_request.target_ref_parts, target_vcs)
1305 1307 except CommitDoesNotExistError:
1306 1308 merge_state = MergeResponse(
1307 1309 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
1308 1310 return merge_state
1309 1311
1310 1312 target_locked = pull_request.target_repo.locked
1311 1313 if target_locked and target_locked[0]:
1312 1314 log.debug("The target repository is locked.")
1313 1315 merge_state = MergeResponse(
1314 1316 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
1315 1317 elif force_shadow_repo_refresh or self._needs_merge_state_refresh(
1316 1318 pull_request, target_ref):
1317 1319 log.debug("Refreshing the merge status of the repository.")
1318 1320 merge_state = self._refresh_merge_state(
1319 1321 pull_request, target_vcs, target_ref)
1320 1322 else:
1321 1323 possible = pull_request.\
1322 1324 last_merge_status == MergeFailureReason.NONE
1323 1325 merge_state = MergeResponse(
1324 1326 possible, False, None, pull_request.last_merge_status)
1325 1327
1326 1328 return merge_state
1327 1329
1328 1330 def _refresh_reference(self, reference, vcs_repository):
1329 if reference.type in ('branch', 'book'):
1331 if reference.type in self.UPDATABLE_REF_TYPES:
1330 1332 name_or_id = reference.name
1331 1333 else:
1332 1334 name_or_id = reference.commit_id
1333 1335 refreshed_commit = vcs_repository.get_commit(name_or_id)
1334 1336 refreshed_reference = Reference(
1335 1337 reference.type, reference.name, refreshed_commit.raw_id)
1336 1338 return refreshed_reference
1337 1339
1338 1340 def _needs_merge_state_refresh(self, pull_request, target_reference):
1339 1341 return not(
1340 1342 pull_request.revisions and
1341 1343 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1342 1344 target_reference.commit_id == pull_request._last_merge_target_rev)
1343 1345
1344 1346 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1345 1347 workspace_id = self._workspace_id(pull_request)
1346 1348 source_vcs = pull_request.source_repo.scm_instance()
1347 1349 repo_id = pull_request.target_repo.repo_id
1348 1350 use_rebase = self._use_rebase_for_merging(pull_request)
1349 1351 close_branch = self._close_branch_before_merging(pull_request)
1350 1352 merge_state = target_vcs.merge(
1351 1353 repo_id, workspace_id,
1352 1354 target_reference, source_vcs, pull_request.source_ref_parts,
1353 1355 dry_run=True, use_rebase=use_rebase,
1354 1356 close_branch=close_branch)
1355 1357
1356 1358 # Do not store the response if there was an unknown error.
1357 1359 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1358 1360 pull_request._last_merge_source_rev = \
1359 1361 pull_request.source_ref_parts.commit_id
1360 1362 pull_request._last_merge_target_rev = target_reference.commit_id
1361 1363 pull_request.last_merge_status = merge_state.failure_reason
1362 1364 pull_request.shadow_merge_ref = merge_state.merge_ref
1363 1365 Session().add(pull_request)
1364 1366 Session().commit()
1365 1367
1366 1368 return merge_state
1367 1369
1368 1370 def _workspace_id(self, pull_request):
1369 1371 workspace_id = 'pr-%s' % pull_request.pull_request_id
1370 1372 return workspace_id
1371 1373
1372 1374 def merge_status_message(self, status_code):
1373 1375 """
1374 1376 Return a human friendly error message for the given merge status code.
1375 1377 """
1376 1378 return self.MERGE_STATUS_MESSAGES[status_code]
1377 1379
1378 1380 def generate_repo_data(self, repo, commit_id=None, branch=None,
1379 1381 bookmark=None, translator=None):
1380 1382 from rhodecode.model.repo import RepoModel
1381 1383
1382 1384 all_refs, selected_ref = \
1383 1385 self._get_repo_pullrequest_sources(
1384 1386 repo.scm_instance(), commit_id=commit_id,
1385 1387 branch=branch, bookmark=bookmark, translator=translator)
1386 1388
1387 1389 refs_select2 = []
1388 1390 for element in all_refs:
1389 1391 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1390 1392 refs_select2.append({'text': element[1], 'children': children})
1391 1393
1392 1394 return {
1393 1395 'user': {
1394 1396 'user_id': repo.user.user_id,
1395 1397 'username': repo.user.username,
1396 1398 'firstname': repo.user.first_name,
1397 1399 'lastname': repo.user.last_name,
1398 1400 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1399 1401 },
1400 1402 'name': repo.repo_name,
1401 1403 'link': RepoModel().get_url(repo),
1402 1404 'description': h.chop_at_smart(repo.description_safe, '\n'),
1403 1405 'refs': {
1404 1406 'all_refs': all_refs,
1405 1407 'selected_ref': selected_ref,
1406 1408 'select2_refs': refs_select2
1407 1409 }
1408 1410 }
1409 1411
1410 1412 def generate_pullrequest_title(self, source, source_ref, target):
1411 1413 return u'{source}#{at_ref} to {target}'.format(
1412 1414 source=source,
1413 1415 at_ref=source_ref,
1414 1416 target=target,
1415 1417 )
1416 1418
1417 1419 def _cleanup_merge_workspace(self, pull_request):
1418 1420 # Merging related cleanup
1419 1421 repo_id = pull_request.target_repo.repo_id
1420 1422 target_scm = pull_request.target_repo.scm_instance()
1421 1423 workspace_id = self._workspace_id(pull_request)
1422 1424
1423 1425 try:
1424 1426 target_scm.cleanup_merge_workspace(repo_id, workspace_id)
1425 1427 except NotImplementedError:
1426 1428 pass
1427 1429
1428 1430 def _get_repo_pullrequest_sources(
1429 1431 self, repo, commit_id=None, branch=None, bookmark=None,
1430 1432 translator=None):
1431 1433 """
1432 1434 Return a structure with repo's interesting commits, suitable for
1433 1435 the selectors in pullrequest controller
1434 1436
1435 1437 :param commit_id: a commit that must be in the list somehow
1436 1438 and selected by default
1437 1439 :param branch: a branch that must be in the list and selected
1438 1440 by default - even if closed
1439 1441 :param bookmark: a bookmark that must be in the list and selected
1440 1442 """
1441 1443 _ = translator or get_current_request().translate
1442 1444
1443 1445 commit_id = safe_str(commit_id) if commit_id else None
1444 1446 branch = safe_str(branch) if branch else None
1445 1447 bookmark = safe_str(bookmark) if bookmark else None
1446 1448
1447 1449 selected = None
1448 1450
1449 1451 # order matters: first source that has commit_id in it will be selected
1450 1452 sources = []
1451 1453 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1452 1454 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1453 1455
1454 1456 if commit_id:
1455 1457 ref_commit = (h.short_id(commit_id), commit_id)
1456 1458 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1457 1459
1458 1460 sources.append(
1459 1461 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1460 1462 )
1461 1463
1462 1464 groups = []
1463 1465 for group_key, ref_list, group_name, match in sources:
1464 1466 group_refs = []
1465 1467 for ref_name, ref_id in ref_list:
1466 1468 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1467 1469 group_refs.append((ref_key, ref_name))
1468 1470
1469 1471 if not selected:
1470 1472 if set([commit_id, match]) & set([ref_id, ref_name]):
1471 1473 selected = ref_key
1472 1474
1473 1475 if group_refs:
1474 1476 groups.append((group_refs, group_name))
1475 1477
1476 1478 if not selected:
1477 1479 ref = commit_id or branch or bookmark
1478 1480 if ref:
1479 1481 raise CommitDoesNotExistError(
1480 1482 'No commit refs could be found matching: %s' % ref)
1481 1483 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1482 1484 selected = 'branch:%s:%s' % (
1483 1485 repo.DEFAULT_BRANCH_NAME,
1484 1486 repo.branches[repo.DEFAULT_BRANCH_NAME]
1485 1487 )
1486 1488 elif repo.commit_ids:
1487 1489 # make the user select in this case
1488 1490 selected = None
1489 1491 else:
1490 1492 raise EmptyRepositoryError()
1491 1493 return groups, selected
1492 1494
1493 1495 def get_diff(self, source_repo, source_ref_id, target_ref_id,
1494 1496 hide_whitespace_changes, diff_context):
1495 1497
1496 1498 return self._get_diff_from_pr_or_version(
1497 1499 source_repo, source_ref_id, target_ref_id,
1498 1500 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1499 1501
1500 1502 def _get_diff_from_pr_or_version(
1501 1503 self, source_repo, source_ref_id, target_ref_id,
1502 1504 hide_whitespace_changes, diff_context):
1503 1505
1504 1506 target_commit = source_repo.get_commit(
1505 1507 commit_id=safe_str(target_ref_id))
1506 1508 source_commit = source_repo.get_commit(
1507 1509 commit_id=safe_str(source_ref_id))
1508 1510 if isinstance(source_repo, Repository):
1509 1511 vcs_repo = source_repo.scm_instance()
1510 1512 else:
1511 1513 vcs_repo = source_repo
1512 1514
1513 1515 # TODO: johbo: In the context of an update, we cannot reach
1514 1516 # the old commit anymore with our normal mechanisms. It needs
1515 1517 # some sort of special support in the vcs layer to avoid this
1516 1518 # workaround.
1517 1519 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1518 1520 vcs_repo.alias == 'git'):
1519 1521 source_commit.raw_id = safe_str(source_ref_id)
1520 1522
1521 1523 log.debug('calculating diff between '
1522 1524 'source_ref:%s and target_ref:%s for repo `%s`',
1523 1525 target_ref_id, source_ref_id,
1524 1526 safe_unicode(vcs_repo.path))
1525 1527
1526 1528 vcs_diff = vcs_repo.get_diff(
1527 1529 commit1=target_commit, commit2=source_commit,
1528 1530 ignore_whitespace=hide_whitespace_changes, context=diff_context)
1529 1531 return vcs_diff
1530 1532
1531 1533 def _is_merge_enabled(self, pull_request):
1532 1534 return self._get_general_setting(
1533 1535 pull_request, 'rhodecode_pr_merge_enabled')
1534 1536
1535 1537 def _use_rebase_for_merging(self, pull_request):
1536 1538 repo_type = pull_request.target_repo.repo_type
1537 1539 if repo_type == 'hg':
1538 1540 return self._get_general_setting(
1539 1541 pull_request, 'rhodecode_hg_use_rebase_for_merging')
1540 1542 elif repo_type == 'git':
1541 1543 return self._get_general_setting(
1542 1544 pull_request, 'rhodecode_git_use_rebase_for_merging')
1543 1545
1544 1546 return False
1545 1547
1546 1548 def _close_branch_before_merging(self, pull_request):
1547 1549 repo_type = pull_request.target_repo.repo_type
1548 1550 if repo_type == 'hg':
1549 1551 return self._get_general_setting(
1550 1552 pull_request, 'rhodecode_hg_close_branch_before_merging')
1551 1553 elif repo_type == 'git':
1552 1554 return self._get_general_setting(
1553 1555 pull_request, 'rhodecode_git_close_branch_before_merging')
1554 1556
1555 1557 return False
1556 1558
1557 1559 def _get_general_setting(self, pull_request, settings_key, default=False):
1558 1560 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1559 1561 settings = settings_model.get_general_settings()
1560 1562 return settings.get(settings_key, default)
1561 1563
1562 1564 def _log_audit_action(self, action, action_data, user, pull_request):
1563 1565 audit_logger.store(
1564 1566 action=action,
1565 1567 action_data=action_data,
1566 1568 user=user,
1567 1569 repo=pull_request.target_repo)
1568 1570
1569 1571 def get_reviewer_functions(self):
1570 1572 """
1571 1573 Fetches functions for validation and fetching default reviewers.
1572 1574 If available we use the EE package, else we fallback to CE
1573 1575 package functions
1574 1576 """
1575 1577 try:
1576 1578 from rc_reviewers.utils import get_default_reviewers_data
1577 1579 from rc_reviewers.utils import validate_default_reviewers
1578 1580 except ImportError:
1579 1581 from rhodecode.apps.repository.utils import get_default_reviewers_data
1580 1582 from rhodecode.apps.repository.utils import validate_default_reviewers
1581 1583
1582 1584 return get_default_reviewers_data, validate_default_reviewers
1583 1585
1584 1586
1585 1587 class MergeCheck(object):
1586 1588 """
1587 1589 Perform Merge Checks and returns a check object which stores information
1588 1590 about merge errors, and merge conditions
1589 1591 """
1590 1592 TODO_CHECK = 'todo'
1591 1593 PERM_CHECK = 'perm'
1592 1594 REVIEW_CHECK = 'review'
1593 1595 MERGE_CHECK = 'merge'
1594 1596
1595 1597 def __init__(self):
1596 1598 self.review_status = None
1597 1599 self.merge_possible = None
1598 1600 self.merge_msg = ''
1599 1601 self.failed = None
1600 1602 self.errors = []
1601 1603 self.error_details = OrderedDict()
1602 1604
1603 1605 def push_error(self, error_type, message, error_key, details):
1604 1606 self.failed = True
1605 1607 self.errors.append([error_type, message])
1606 1608 self.error_details[error_key] = dict(
1607 1609 details=details,
1608 1610 error_type=error_type,
1609 1611 message=message
1610 1612 )
1611 1613
1612 1614 @classmethod
1613 1615 def validate(cls, pull_request, auth_user, translator, fail_early=False,
1614 1616 force_shadow_repo_refresh=False):
1615 1617 _ = translator
1616 1618 merge_check = cls()
1617 1619
1618 1620 # permissions to merge
1619 1621 user_allowed_to_merge = PullRequestModel().check_user_merge(
1620 1622 pull_request, auth_user)
1621 1623 if not user_allowed_to_merge:
1622 1624 log.debug("MergeCheck: cannot merge, approval is pending.")
1623 1625
1624 1626 msg = _('User `{}` not allowed to perform merge.').format(auth_user.username)
1625 1627 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1626 1628 if fail_early:
1627 1629 return merge_check
1628 1630
1629 1631 # permission to merge into the target branch
1630 1632 target_commit_id = pull_request.target_ref_parts.commit_id
1631 1633 if pull_request.target_ref_parts.type == 'branch':
1632 1634 branch_name = pull_request.target_ref_parts.name
1633 1635 else:
1634 1636 # for mercurial we can always figure out the branch from the commit
1635 1637 # in case of bookmark
1636 1638 target_commit = pull_request.target_repo.get_commit(target_commit_id)
1637 1639 branch_name = target_commit.branch
1638 1640
1639 1641 rule, branch_perm = auth_user.get_rule_and_branch_permission(
1640 1642 pull_request.target_repo.repo_name, branch_name)
1641 1643 if branch_perm and branch_perm == 'branch.none':
1642 1644 msg = _('Target branch `{}` changes rejected by rule {}.').format(
1643 1645 branch_name, rule)
1644 1646 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1645 1647 if fail_early:
1646 1648 return merge_check
1647 1649
1648 1650 # review status, must be always present
1649 1651 review_status = pull_request.calculated_review_status()
1650 1652 merge_check.review_status = review_status
1651 1653
1652 1654 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1653 1655 if not status_approved:
1654 1656 log.debug("MergeCheck: cannot merge, approval is pending.")
1655 1657
1656 1658 msg = _('Pull request reviewer approval is pending.')
1657 1659
1658 1660 merge_check.push_error(
1659 1661 'warning', msg, cls.REVIEW_CHECK, review_status)
1660 1662
1661 1663 if fail_early:
1662 1664 return merge_check
1663 1665
1664 1666 # left over TODOs
1665 1667 todos = CommentsModel().get_unresolved_todos(pull_request)
1666 1668 if todos:
1667 1669 log.debug("MergeCheck: cannot merge, {} "
1668 1670 "unresolved todos left.".format(len(todos)))
1669 1671
1670 1672 if len(todos) == 1:
1671 1673 msg = _('Cannot merge, {} TODO still not resolved.').format(
1672 1674 len(todos))
1673 1675 else:
1674 1676 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1675 1677 len(todos))
1676 1678
1677 1679 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1678 1680
1679 1681 if fail_early:
1680 1682 return merge_check
1681 1683
1682 1684 # merge possible, here is the filesystem simulation + shadow repo
1683 1685 merge_status, msg = PullRequestModel().merge_status(
1684 1686 pull_request, translator=translator,
1685 1687 force_shadow_repo_refresh=force_shadow_repo_refresh)
1686 1688 merge_check.merge_possible = merge_status
1687 1689 merge_check.merge_msg = msg
1688 1690 if not merge_status:
1689 1691 log.debug(
1690 1692 "MergeCheck: cannot merge, pull request merge not possible.")
1691 1693 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1692 1694
1693 1695 if fail_early:
1694 1696 return merge_check
1695 1697
1696 1698 log.debug('MergeCheck: is failed: %s', merge_check.failed)
1697 1699 return merge_check
1698 1700
1699 1701 @classmethod
1700 1702 def get_merge_conditions(cls, pull_request, translator):
1701 1703 _ = translator
1702 1704 merge_details = {}
1703 1705
1704 1706 model = PullRequestModel()
1705 1707 use_rebase = model._use_rebase_for_merging(pull_request)
1706 1708
1707 1709 if use_rebase:
1708 1710 merge_details['merge_strategy'] = dict(
1709 1711 details={},
1710 1712 message=_('Merge strategy: rebase')
1711 1713 )
1712 1714 else:
1713 1715 merge_details['merge_strategy'] = dict(
1714 1716 details={},
1715 1717 message=_('Merge strategy: explicit merge commit')
1716 1718 )
1717 1719
1718 1720 close_branch = model._close_branch_before_merging(pull_request)
1719 1721 if close_branch:
1720 1722 repo_type = pull_request.target_repo.repo_type
1721 1723 if repo_type == 'hg':
1722 1724 close_msg = _('Source branch will be closed after merge.')
1723 1725 elif repo_type == 'git':
1724 1726 close_msg = _('Source branch will be deleted after merge.')
1725 1727
1726 1728 merge_details['close_branch'] = dict(
1727 1729 details={},
1728 1730 message=close_msg
1729 1731 )
1730 1732
1731 1733 return merge_details
1732 1734
1733 1735 ChangeTuple = collections.namedtuple(
1734 1736 'ChangeTuple', ['added', 'common', 'removed', 'total'])
1735 1737
1736 1738 FileChangeTuple = collections.namedtuple(
1737 1739 'FileChangeTuple', ['added', 'modified', 'removed'])
General Comments 0
You need to be logged in to leave comments. Login now