##// END OF EJS Templates
pull-requests: add merge validation to prevent merges to protected branches.
marcink -
r2981:1e14730d default
parent child Browse files
Show More
@@ -1,337 +1,338 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 59 def test_create_with_correct_data(self, backend):
60 60 data = self._prepare_data(backend)
61 61 RepoModel().revoke_user_permission(
62 62 self.source.repo_name, User.DEFAULT_USER)
63 63 id_, params = build_data(
64 64 self.apikey_regular, 'create_pull_request', **data)
65 65 response = api_call(self.app, params)
66 66 expected_message = "Created new pull request `{title}`".format(
67 67 title=data['title'])
68 68 result = response.json
69 69 assert result['result']['msg'] == expected_message
70 70 pull_request_id = result['result']['pull_request_id']
71 71 pull_request = PullRequestModel().get(pull_request_id)
72 72 assert pull_request.title == data['title']
73 73 assert pull_request.description == data['description']
74 74 assert pull_request.source_ref == data['source_ref']
75 75 assert pull_request.target_ref == data['target_ref']
76 76 assert pull_request.source_repo.repo_name == data['source_repo']
77 77 assert pull_request.target_repo.repo_name == data['target_repo']
78 78 assert pull_request.revisions == [self.commit_ids['change']]
79 79 assert len(pull_request.reviewers) == 1
80 80
81 81 @pytest.mark.backends("git", "hg")
82 82 def test_create_with_empty_description(self, backend):
83 83 data = self._prepare_data(backend)
84 84 data.pop('description')
85 85 id_, params = build_data(
86 86 self.apikey_regular, 'create_pull_request', **data)
87 87 response = api_call(self.app, params)
88 88 expected_message = "Created new pull request `{title}`".format(
89 89 title=data['title'])
90 90 result = response.json
91 91 assert result['result']['msg'] == expected_message
92 92 pull_request_id = result['result']['pull_request_id']
93 93 pull_request = PullRequestModel().get(pull_request_id)
94 94 assert pull_request.description == ''
95 95
96 96 @pytest.mark.backends("git", "hg")
97 97 def test_create_with_empty_title(self, backend):
98 98 data = self._prepare_data(backend)
99 99 data.pop('title')
100 100 id_, params = build_data(
101 101 self.apikey_regular, 'create_pull_request', **data)
102 102 response = api_call(self.app, params)
103 103 result = response.json
104 104 pull_request_id = result['result']['pull_request_id']
105 105 pull_request = PullRequestModel().get(pull_request_id)
106 106 data['ref'] = backend.default_branch_name
107 107 title = '{source_repo}#{ref} to {target_repo}'.format(**data)
108 108 assert pull_request.title == title
109 109
110 110 @pytest.mark.backends("git", "hg")
111 111 def test_create_with_reviewers_specified_by_names(
112 112 self, backend, no_notifications):
113 113 data = self._prepare_data(backend)
114 114 reviewers = [
115 115 {'username': TEST_USER_REGULAR_LOGIN,
116 116 'reasons': ['{} added manually'.format(TEST_USER_REGULAR_LOGIN)]},
117 117 {'username': TEST_USER_ADMIN_LOGIN,
118 118 'reasons': ['{} added manually'.format(TEST_USER_ADMIN_LOGIN)],
119 119 'mandatory': True},
120 120 ]
121 121 data['reviewers'] = reviewers
122 122
123 123 id_, params = build_data(
124 124 self.apikey_regular, 'create_pull_request', **data)
125 125 response = api_call(self.app, params)
126 126
127 127 expected_message = "Created new pull request `{title}`".format(
128 128 title=data['title'])
129 129 result = response.json
130 130 assert result['result']['msg'] == expected_message
131 131 pull_request_id = result['result']['pull_request_id']
132 132 pull_request = PullRequestModel().get(pull_request_id)
133 133
134 134 actual_reviewers = []
135 135 for rev in pull_request.reviewers:
136 136 entry = {
137 137 'username': rev.user.username,
138 138 'reasons': rev.reasons,
139 139 }
140 140 if rev.mandatory:
141 141 entry['mandatory'] = rev.mandatory
142 142 actual_reviewers.append(entry)
143 143
144 144 # default reviewer will be added who is an owner of the repo
145 145 reviewers.append(
146 146 {'username': pull_request.author.username,
147 147 'reasons': [u'Default reviewer', u'Repository owner']},
148 148 )
149 149 assert sorted(actual_reviewers, key=lambda e: e['username']) \
150 150 == sorted(reviewers, key=lambda e: e['username'])
151 151
152 152 @pytest.mark.backends("git", "hg")
153 153 def test_create_with_reviewers_specified_by_ids(
154 154 self, backend, no_notifications):
155 155 data = self._prepare_data(backend)
156 156 reviewers = [
157 157 {'username': UserModel().get_by_username(
158 158 TEST_USER_REGULAR_LOGIN).user_id,
159 159 'reasons': ['added manually']},
160 160 {'username': UserModel().get_by_username(
161 161 TEST_USER_ADMIN_LOGIN).user_id,
162 162 'reasons': ['added manually']},
163 163 ]
164 164
165 165 data['reviewers'] = reviewers
166 166 id_, params = build_data(
167 167 self.apikey_regular, 'create_pull_request', **data)
168 168 response = api_call(self.app, params)
169 169
170 170 expected_message = "Created new pull request `{title}`".format(
171 171 title=data['title'])
172 172 result = response.json
173 173 assert result['result']['msg'] == expected_message
174 174 pull_request_id = result['result']['pull_request_id']
175 175 pull_request = PullRequestModel().get(pull_request_id)
176 176
177 177 actual_reviewers = []
178 178 for rev in pull_request.reviewers:
179 179 entry = {
180 180 'username': rev.user.user_id,
181 181 'reasons': rev.reasons,
182 182 }
183 183 if rev.mandatory:
184 184 entry['mandatory'] = rev.mandatory
185 185 actual_reviewers.append(entry)
186 186 # default reviewer will be added who is an owner of the repo
187 187 reviewers.append(
188 188 {'username': pull_request.author.user_id,
189 189 'reasons': [u'Default reviewer', u'Repository owner']},
190 190 )
191 191 assert sorted(actual_reviewers, key=lambda e: e['username']) \
192 192 == sorted(reviewers, key=lambda e: e['username'])
193 193
194 194 @pytest.mark.backends("git", "hg")
195 195 def test_create_fails_when_the_reviewer_is_not_found(self, backend):
196 196 data = self._prepare_data(backend)
197 197 data['reviewers'] = [{'username': 'somebody'}]
198 198 id_, params = build_data(
199 199 self.apikey_regular, 'create_pull_request', **data)
200 200 response = api_call(self.app, params)
201 201 expected_message = 'user `somebody` does not exist'
202 202 assert_error(id_, expected_message, given=response.body)
203 203
204 204 @pytest.mark.backends("git", "hg")
205 205 def test_cannot_create_with_reviewers_in_wrong_format(self, backend):
206 206 data = self._prepare_data(backend)
207 207 reviewers = ','.join([TEST_USER_REGULAR_LOGIN, TEST_USER_ADMIN_LOGIN])
208 208 data['reviewers'] = reviewers
209 209 id_, params = build_data(
210 210 self.apikey_regular, 'create_pull_request', **data)
211 211 response = api_call(self.app, params)
212 212 expected_message = {u'': '"test_regular,test_admin" is not iterable'}
213 213 assert_error(id_, expected_message, given=response.body)
214 214
215 215 @pytest.mark.backends("git", "hg")
216 216 def test_create_with_no_commit_hashes(self, backend):
217 217 data = self._prepare_data(backend)
218 218 expected_source_ref = data['source_ref']
219 219 expected_target_ref = data['target_ref']
220 220 data['source_ref'] = 'branch:{}'.format(backend.default_branch_name)
221 221 data['target_ref'] = 'branch:{}'.format(backend.default_branch_name)
222 222 id_, params = build_data(
223 223 self.apikey_regular, 'create_pull_request', **data)
224 224 response = api_call(self.app, params)
225 225 expected_message = "Created new pull request `{title}`".format(
226 226 title=data['title'])
227 227 result = response.json
228 228 assert result['result']['msg'] == expected_message
229 229 pull_request_id = result['result']['pull_request_id']
230 230 pull_request = PullRequestModel().get(pull_request_id)
231 231 assert pull_request.source_ref == expected_source_ref
232 232 assert pull_request.target_ref == expected_target_ref
233 233
234 234 @pytest.mark.backends("git", "hg")
235 235 @pytest.mark.parametrize("data_key", ["source_repo", "target_repo"])
236 236 def test_create_fails_with_wrong_repo(self, backend, data_key):
237 237 repo_name = 'fake-repo'
238 238 data = self._prepare_data(backend)
239 239 data[data_key] = repo_name
240 240 id_, params = build_data(
241 241 self.apikey_regular, 'create_pull_request', **data)
242 242 response = api_call(self.app, params)
243 243 expected_message = 'repository `{}` does not exist'.format(repo_name)
244 244 assert_error(id_, expected_message, given=response.body)
245 245
246 246 @pytest.mark.backends("git", "hg")
247 247 @pytest.mark.parametrize("data_key", ["source_ref", "target_ref"])
248 248 def test_create_fails_with_non_existing_branch(self, backend, data_key):
249 249 branch_name = 'test-branch'
250 250 data = self._prepare_data(backend)
251 251 data[data_key] = "branch:{}".format(branch_name)
252 252 id_, params = build_data(
253 253 self.apikey_regular, 'create_pull_request', **data)
254 254 response = api_call(self.app, params)
255 expected_message = 'The specified branch `{}` does not exist'.format(
256 branch_name)
255 expected_message = 'The specified value:{type}:`{name}` ' \
256 'does not exist, or is not allowed.'.format(type='branch',
257 name=branch_name)
257 258 assert_error(id_, expected_message, given=response.body)
258 259
259 260 @pytest.mark.backends("git", "hg")
260 261 @pytest.mark.parametrize("data_key", ["source_ref", "target_ref"])
261 262 def test_create_fails_with_ref_in_a_wrong_format(self, backend, data_key):
262 263 data = self._prepare_data(backend)
263 264 ref = 'stange-ref'
264 265 data[data_key] = ref
265 266 id_, params = build_data(
266 267 self.apikey_regular, 'create_pull_request', **data)
267 268 response = api_call(self.app, params)
268 269 expected_message = (
269 270 'Ref `{ref}` given in a wrong format. Please check the API'
270 271 ' documentation for more details'.format(ref=ref))
271 272 assert_error(id_, expected_message, given=response.body)
272 273
273 274 @pytest.mark.backends("git", "hg")
274 275 @pytest.mark.parametrize("data_key", ["source_ref", "target_ref"])
275 276 def test_create_fails_with_non_existing_ref(self, backend, data_key):
276 277 commit_id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa10'
277 278 ref = self._get_full_ref(backend, commit_id)
278 279 data = self._prepare_data(backend)
279 280 data[data_key] = ref
280 281 id_, params = build_data(
281 282 self.apikey_regular, 'create_pull_request', **data)
282 283 response = api_call(self.app, params)
283 284 expected_message = 'Ref `{}` does not exist'.format(ref)
284 285 assert_error(id_, expected_message, given=response.body)
285 286
286 287 @pytest.mark.backends("git", "hg")
287 288 def test_create_fails_when_no_revisions(self, backend):
288 289 data = self._prepare_data(backend, source_head='initial')
289 290 id_, params = build_data(
290 291 self.apikey_regular, 'create_pull_request', **data)
291 292 response = api_call(self.app, params)
292 293 expected_message = 'no commits found'
293 294 assert_error(id_, expected_message, given=response.body)
294 295
295 296 @pytest.mark.backends("git", "hg")
296 297 def test_create_fails_when_no_permissions(self, backend):
297 298 data = self._prepare_data(backend)
298 299 RepoModel().revoke_user_permission(
299 300 self.source.repo_name, self.test_user)
300 301 RepoModel().revoke_user_permission(
301 302 self.source.repo_name, User.DEFAULT_USER)
302 303
303 304 id_, params = build_data(
304 305 self.apikey_regular, 'create_pull_request', **data)
305 306 response = api_call(self.app, params)
306 307 expected_message = 'repository `{}` does not exist'.format(
307 308 self.source.repo_name)
308 309 assert_error(id_, expected_message, given=response.body)
309 310
310 311 def _prepare_data(
311 312 self, backend, source_head='change', target_head='initial'):
312 313 commits = [
313 314 {'message': 'initial'},
314 315 {'message': 'change'},
315 316 {'message': 'new-feature', 'parents': ['initial']},
316 317 ]
317 318 self.commit_ids = backend.create_master_repo(commits)
318 319 self.source = backend.create_repo(heads=[source_head])
319 320 self.target = backend.create_repo(heads=[target_head])
320 321
321 322 data = {
322 323 'source_repo': self.source.repo_name,
323 324 'target_repo': self.target.repo_name,
324 325 'source_ref': self._get_full_ref(
325 326 backend, self.commit_ids[source_head]),
326 327 'target_ref': self._get_full_ref(
327 328 backend, self.commit_ids[target_head]),
328 329 'title': 'Test PR 1',
329 330 'description': 'Test'
330 331 }
331 332 RepoModel().grant_user_permission(
332 333 self.source.repo_name, self.TEST_USER_LOGIN, 'repository.read')
333 334 return data
334 335
335 336 def _get_full_ref(self, backend, commit_id):
336 337 return 'branch:{branch}:{commit_id}'.format(
337 338 branch=backend.default_branch_name, commit_id=commit_id)
@@ -1,294 +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 87 ref = 'ancestor:ref'
88 88 with pytest.raises(JSONRPCError) as excinfo:
89 89 utils.resolve_ref_or_error(ref, repo)
90 expected_message = 'The specified ancestor `ref` does not exist'
90 expected_message = (
91 'The specified value:ancestor:`ref` does not exist, or is not allowed.')
91 92 assert excinfo.value.message == expected_message
92 93
93 94 def test_branch_is_not_found(self):
94 95 repo = Mock()
95 96 ref = 'branch:non-existing-one'
96 97 with patch('rhodecode.api.utils._get_ref_hash')\
97 98 as _get_ref_hash:
98 99 _get_ref_hash.side_effect = KeyError()
99 100 with pytest.raises(JSONRPCError) as excinfo:
100 101 utils.resolve_ref_or_error(ref, repo)
101 102 expected_message = (
102 'The specified branch `non-existing-one` does not exist')
103 'The specified value:branch:`non-existing-one` does not exist, or is not allowed.')
103 104 assert excinfo.value.message == expected_message
104 105
105 106 def test_bookmark_is_not_found(self):
106 107 repo = Mock()
107 108 ref = 'bookmark:non-existing-one'
108 109 with patch('rhodecode.api.utils._get_ref_hash')\
109 110 as _get_ref_hash:
110 111 _get_ref_hash.side_effect = KeyError()
111 112 with pytest.raises(JSONRPCError) as excinfo:
112 113 utils.resolve_ref_or_error(ref, repo)
113 114 expected_message = (
114 'The specified bookmark `non-existing-one` does not exist')
115 'The specified value:bookmark:`non-existing-one` does not exist, or is not allowed.')
115 116 assert excinfo.value.message == expected_message
116 117
117 118 @pytest.mark.parametrize("ref", ['ref', '12345', 'a:b:c:d'])
118 119 def test_ref_cannot_be_parsed(self, ref):
119 120 repo = Mock()
120 121 with pytest.raises(JSONRPCError) as excinfo:
121 122 utils.resolve_ref_or_error(ref, repo)
122 123 expected_message = (
123 124 'Ref `{ref}` given in a wrong format. Please check the API'
124 125 ' documentation for more details'.format(ref=ref)
125 126 )
126 127 assert excinfo.value.message == expected_message
127 128
128 129
129 130 class TestGetRefHash(object):
130 131 def setup(self):
131 132 self.commit_hash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa10'
132 133 self.bookmark_name = 'test-bookmark'
133 134
134 135 @pytest.mark.parametrize("alias, branch_name", [
135 136 ("git", "master"),
136 137 ("hg", "default")
137 138 ])
138 139 def test_returns_hash_by_branch_name(self, alias, branch_name):
139 140 with patch('rhodecode.model.db.Repository') as repo:
140 141 repo.scm_instance().alias = alias
141 142 repo.scm_instance().branches = {branch_name: self.commit_hash}
142 143 result_hash = utils._get_ref_hash(repo, 'branch', branch_name)
143 144 assert result_hash == self.commit_hash
144 145
145 146 @pytest.mark.parametrize("alias, branch_name", [
146 147 ("git", "master"),
147 148 ("hg", "default")
148 149 ])
149 150 def test_raises_error_when_branch_is_not_found(self, alias, branch_name):
150 151 with patch('rhodecode.model.db.Repository') as repo:
151 152 repo.scm_instance().alias = alias
152 153 repo.scm_instance().branches = {}
153 154 with pytest.raises(KeyError):
154 155 utils._get_ref_hash(repo, 'branch', branch_name)
155 156
156 157 def test_returns_hash_when_bookmark_is_specified_for_hg(self):
157 158 with patch('rhodecode.model.db.Repository') as repo:
158 159 repo.scm_instance().alias = 'hg'
159 160 repo.scm_instance().bookmarks = {
160 161 self.bookmark_name: self.commit_hash}
161 162 result_hash = utils._get_ref_hash(
162 163 repo, 'bookmark', self.bookmark_name)
163 164 assert result_hash == self.commit_hash
164 165
165 166 def test_raises_error_when_bookmark_is_not_found_in_hg_repo(self):
166 167 with patch('rhodecode.model.db.Repository') as repo:
167 168 repo.scm_instance().alias = 'hg'
168 169 repo.scm_instance().bookmarks = {}
169 170 with pytest.raises(KeyError):
170 171 utils._get_ref_hash(repo, 'bookmark', self.bookmark_name)
171 172
172 173 def test_raises_error_when_bookmark_is_specified_for_git(self):
173 174 with patch('rhodecode.model.db.Repository') as repo:
174 175 repo.scm_instance().alias = 'git'
175 176 repo.scm_instance().bookmarks = {
176 177 self.bookmark_name: self.commit_hash}
177 178 with pytest.raises(ValueError):
178 179 utils._get_ref_hash(repo, 'bookmark', self.bookmark_name)
179 180
180 181
181 182 class TestUserByNameOrError(object):
182 183 def test_user_found_by_id(self):
183 184 fake_user = Mock(id=123)
184 185
185 186 patcher = patch('rhodecode.model.user.UserModel.get_user')
186 187 with patcher as get_user:
187 188 get_user.return_value = fake_user
188 189
189 190 patcher = patch('rhodecode.model.user.UserModel.get_by_username')
190 191 with patcher as get_by_username:
191 192 result = utils.get_user_or_error(123)
192 193 assert result == fake_user
193 194
194 195 def test_user_not_found_by_id_as_str(self):
195 196 fake_user = Mock(id=123)
196 197
197 198 patcher = patch('rhodecode.model.user.UserModel.get_user')
198 199 with patcher as get_user:
199 200 get_user.return_value = fake_user
200 201 patcher = patch('rhodecode.model.user.UserModel.get_by_username')
201 202 with patcher as get_by_username:
202 203 get_by_username.return_value = None
203 204
204 205 with pytest.raises(JSONRPCError):
205 206 utils.get_user_or_error('123')
206 207
207 208 def test_user_found_by_name(self):
208 209 fake_user = Mock(id=123)
209 210
210 211 patcher = patch('rhodecode.model.user.UserModel.get_user')
211 212 with patcher as get_user:
212 213 get_user.return_value = None
213 214
214 215 patcher = patch('rhodecode.model.user.UserModel.get_by_username')
215 216 with patcher as get_by_username:
216 217 get_by_username.return_value = fake_user
217 218
218 219 result = utils.get_user_or_error('test')
219 220 assert result == fake_user
220 221
221 222 def test_user_not_found_by_id(self):
222 223 patcher = patch('rhodecode.model.user.UserModel.get_user')
223 224 with patcher as get_user:
224 225 get_user.return_value = None
225 226 patcher = patch('rhodecode.model.user.UserModel.get_by_username')
226 227 with patcher as get_by_username:
227 228 get_by_username.return_value = None
228 229
229 230 with pytest.raises(JSONRPCError) as excinfo:
230 231 utils.get_user_or_error(123)
231 232
232 233 expected_message = 'user `123` does not exist'
233 234 assert excinfo.value.message == expected_message
234 235
235 236 def test_user_not_found_by_name(self):
236 237 patcher = patch('rhodecode.model.user.UserModel.get_by_username')
237 238 with patcher as get_by_username:
238 239 get_by_username.return_value = None
239 240 with pytest.raises(JSONRPCError) as excinfo:
240 241 utils.get_user_or_error('test')
241 242
242 243 expected_message = 'user `test` does not exist'
243 244 assert excinfo.value.message == expected_message
244 245
245 246
246 247 class TestGetCommitDict(object):
247 248 @pytest.mark.parametrize('filename, expected', [
248 249 (b'sp\xc3\xa4cial', u'sp\xe4cial'),
249 250 (b'sp\xa4cial', u'sp\ufffdcial'),
250 251 ])
251 252 def test_decodes_filenames_to_unicode(self, filename, expected):
252 253 result = utils._get_commit_dict(filename=filename, op='A')
253 254 assert result['filename'] == expected
254 255
255 256
256 257 class TestRepoAccess(object):
257 258 def setup_method(self, method):
258 259
259 260 self.admin_perm_patch = patch(
260 261 'rhodecode.api.utils.HasPermissionAnyApi')
261 262 self.repo_perm_patch = patch(
262 263 'rhodecode.api.utils.HasRepoPermissionAnyApi')
263 264
264 265 def test_has_superadmin_permission_checks_for_admin(self):
265 266 admin_mock = Mock()
266 267 with self.admin_perm_patch as amock:
267 268 amock.return_value = admin_mock
268 269 assert utils.has_superadmin_permission('fake_user')
269 270 amock.assert_called_once_with('hg.admin')
270 271
271 272 admin_mock.assert_called_once_with(user='fake_user')
272 273
273 274 def test_has_repo_permissions_checks_for_repo_access(self):
274 275 repo_mock = Mock()
275 276 fake_repo = Mock()
276 277 with self.repo_perm_patch as rmock:
277 278 rmock.return_value = repo_mock
278 279 assert utils.validate_repo_permissions(
279 280 'fake_user', 'fake_repo_id', fake_repo,
280 281 ['perm1', 'perm2'])
281 282 rmock.assert_called_once_with(*['perm1', 'perm2'])
282 283
283 284 repo_mock.assert_called_once_with(
284 285 user='fake_user', repo_name=fake_repo.repo_name)
285 286
286 287 def test_has_repo_permissions_raises_not_found(self):
287 288 repo_mock = Mock(return_value=False)
288 289 fake_repo = Mock()
289 290 with self.repo_perm_patch as rmock:
290 291 rmock.return_value = repo_mock
291 292 with pytest.raises(JSONRPCError) as excinfo:
292 293 utils.validate_repo_permissions(
293 294 'fake_user', 'fake_repo_id', fake_repo, 'perms')
294 295 assert 'fake_repo_id' in excinfo
@@ -1,442 +1,441 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 391 def resolve_ref_or_error(ref, repo):
392 392 def _parse_ref(type_, name, hash_=None):
393 393 return type_, name, hash_
394 394
395 395 try:
396 396 ref_type, ref_name, ref_hash = _parse_ref(*ref.split(':'))
397 397 except TypeError:
398 398 raise JSONRPCError(
399 399 'Ref `{ref}` given in a wrong format. Please check the API'
400 400 ' documentation for more details'.format(ref=ref))
401 401
402 402 try:
403 403 ref_hash = ref_hash or _get_ref_hash(repo, ref_type, ref_name)
404 404 except (KeyError, ValueError):
405 405 raise JSONRPCError(
406 'The specified {type} `{name}` does not exist'.format(
406 'The specified value:{type}:`{name}` does not exist, or is not allowed.'.format(
407 407 type=ref_type, name=ref_name))
408 408
409 409 return ':'.join([ref_type, ref_name, ref_hash])
410 410
411 411
412 412 def _get_commit_dict(
413 413 filename, op, new_revision=None, old_revision=None,
414 414 raw_diff=None, stats=None):
415 415 if stats is None:
416 416 stats = {
417 417 "added": None,
418 418 "binary": None,
419 419 "deleted": None
420 420 }
421 421 return {
422 422 "filename": safe_unicode(filename),
423 423 "op": op,
424 424
425 425 # extra details
426 426 "new_revision": new_revision,
427 427 "old_revision": old_revision,
428 428
429 429 "raw_diff": raw_diff,
430 430 "stats": stats
431 431 }
432 432
433 433
434 # TODO: mikhail: Think about moving this function to some library
435 434 def _get_ref_hash(repo, type_, name):
436 435 vcs_repo = repo.scm_instance()
437 436 if type_ == 'branch' and vcs_repo.alias in ('hg', 'git'):
438 437 return vcs_repo.branches[name]
439 438 elif type_ == 'bookmark' and vcs_repo.alias == 'hg':
440 439 return vcs_repo.bookmarks[name]
441 440 else:
442 441 raise ValueError()
@@ -1,937 +1,937 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 import logging
23 23
24 24 from rhodecode import events
25 25 from rhodecode.api import jsonrpc_method, JSONRPCError, JSONRPCValidationError
26 26 from rhodecode.api.utils import (
27 27 has_superadmin_permission, Optional, OAttr, get_repo_or_error,
28 28 get_pull_request_or_error, get_commit_or_error, get_user_or_error,
29 29 validate_repo_permissions, resolve_ref_or_error)
30 30 from rhodecode.lib.auth import (HasRepoPermissionAnyApi)
31 31 from rhodecode.lib.base import vcs_operation_context
32 32 from rhodecode.lib.utils2 import str2bool
33 33 from rhodecode.model.changeset_status import ChangesetStatusModel
34 34 from rhodecode.model.comment import CommentsModel
35 35 from rhodecode.model.db import Session, ChangesetStatus, ChangesetComment
36 36 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
37 37 from rhodecode.model.settings import SettingsModel
38 38 from rhodecode.model.validation_schema import Invalid
39 39 from rhodecode.model.validation_schema.schemas.reviewer_schema import(
40 40 ReviewerListSchema)
41 41
42 42 log = logging.getLogger(__name__)
43 43
44 44
45 45 @jsonrpc_method()
46 46 def get_pull_request(request, apiuser, pullrequestid, repoid=Optional(None)):
47 47 """
48 48 Get a pull request based on the given ID.
49 49
50 50 :param apiuser: This is filled automatically from the |authtoken|.
51 51 :type apiuser: AuthUser
52 52 :param repoid: Optional, repository name or repository ID from where
53 53 the pull request was opened.
54 54 :type repoid: str or int
55 55 :param pullrequestid: ID of the requested pull request.
56 56 :type pullrequestid: int
57 57
58 58 Example output:
59 59
60 60 .. code-block:: bash
61 61
62 62 "id": <id_given_in_input>,
63 63 "result":
64 64 {
65 65 "pull_request_id": "<pull_request_id>",
66 66 "url": "<url>",
67 67 "title": "<title>",
68 68 "description": "<description>",
69 69 "status" : "<status>",
70 70 "created_on": "<date_time_created>",
71 71 "updated_on": "<date_time_updated>",
72 72 "commit_ids": [
73 73 ...
74 74 "<commit_id>",
75 75 "<commit_id>",
76 76 ...
77 77 ],
78 78 "review_status": "<review_status>",
79 79 "mergeable": {
80 80 "status": "<bool>",
81 81 "message": "<message>",
82 82 },
83 83 "source": {
84 84 "clone_url": "<clone_url>",
85 85 "repository": "<repository_name>",
86 86 "reference":
87 87 {
88 88 "name": "<name>",
89 89 "type": "<type>",
90 90 "commit_id": "<commit_id>",
91 91 }
92 92 },
93 93 "target": {
94 94 "clone_url": "<clone_url>",
95 95 "repository": "<repository_name>",
96 96 "reference":
97 97 {
98 98 "name": "<name>",
99 99 "type": "<type>",
100 100 "commit_id": "<commit_id>",
101 101 }
102 102 },
103 103 "merge": {
104 104 "clone_url": "<clone_url>",
105 105 "reference":
106 106 {
107 107 "name": "<name>",
108 108 "type": "<type>",
109 109 "commit_id": "<commit_id>",
110 110 }
111 111 },
112 112 "author": <user_obj>,
113 113 "reviewers": [
114 114 ...
115 115 {
116 116 "user": "<user_obj>",
117 117 "review_status": "<review_status>",
118 118 }
119 119 ...
120 120 ]
121 121 },
122 122 "error": null
123 123 """
124 124
125 125 pull_request = get_pull_request_or_error(pullrequestid)
126 126 if Optional.extract(repoid):
127 127 repo = get_repo_or_error(repoid)
128 128 else:
129 129 repo = pull_request.target_repo
130 130
131 131 if not PullRequestModel().check_user_read(
132 132 pull_request, apiuser, api=True):
133 133 raise JSONRPCError('repository `%s` or pull request `%s` '
134 134 'does not exist' % (repoid, pullrequestid))
135 135 data = pull_request.get_api_data()
136 136 return data
137 137
138 138
139 139 @jsonrpc_method()
140 140 def get_pull_requests(request, apiuser, repoid, status=Optional('new')):
141 141 """
142 142 Get all pull requests from the repository specified in `repoid`.
143 143
144 144 :param apiuser: This is filled automatically from the |authtoken|.
145 145 :type apiuser: AuthUser
146 146 :param repoid: Optional repository name or repository ID.
147 147 :type repoid: str or int
148 148 :param status: Only return pull requests with the specified status.
149 149 Valid options are.
150 150 * ``new`` (default)
151 151 * ``open``
152 152 * ``closed``
153 153 :type status: str
154 154
155 155 Example output:
156 156
157 157 .. code-block:: bash
158 158
159 159 "id": <id_given_in_input>,
160 160 "result":
161 161 [
162 162 ...
163 163 {
164 164 "pull_request_id": "<pull_request_id>",
165 165 "url": "<url>",
166 166 "title" : "<title>",
167 167 "description": "<description>",
168 168 "status": "<status>",
169 169 "created_on": "<date_time_created>",
170 170 "updated_on": "<date_time_updated>",
171 171 "commit_ids": [
172 172 ...
173 173 "<commit_id>",
174 174 "<commit_id>",
175 175 ...
176 176 ],
177 177 "review_status": "<review_status>",
178 178 "mergeable": {
179 179 "status": "<bool>",
180 180 "message: "<message>",
181 181 },
182 182 "source": {
183 183 "clone_url": "<clone_url>",
184 184 "reference":
185 185 {
186 186 "name": "<name>",
187 187 "type": "<type>",
188 188 "commit_id": "<commit_id>",
189 189 }
190 190 },
191 191 "target": {
192 192 "clone_url": "<clone_url>",
193 193 "reference":
194 194 {
195 195 "name": "<name>",
196 196 "type": "<type>",
197 197 "commit_id": "<commit_id>",
198 198 }
199 199 },
200 200 "merge": {
201 201 "clone_url": "<clone_url>",
202 202 "reference":
203 203 {
204 204 "name": "<name>",
205 205 "type": "<type>",
206 206 "commit_id": "<commit_id>",
207 207 }
208 208 },
209 209 "author": <user_obj>,
210 210 "reviewers": [
211 211 ...
212 212 {
213 213 "user": "<user_obj>",
214 214 "review_status": "<review_status>",
215 215 }
216 216 ...
217 217 ]
218 218 }
219 219 ...
220 220 ],
221 221 "error": null
222 222
223 223 """
224 224 repo = get_repo_or_error(repoid)
225 225 if not has_superadmin_permission(apiuser):
226 226 _perms = (
227 227 'repository.admin', 'repository.write', 'repository.read',)
228 228 validate_repo_permissions(apiuser, repoid, repo, _perms)
229 229
230 230 status = Optional.extract(status)
231 231 pull_requests = PullRequestModel().get_all(repo, statuses=[status])
232 232 data = [pr.get_api_data() for pr in pull_requests]
233 233 return data
234 234
235 235
236 236 @jsonrpc_method()
237 237 def merge_pull_request(
238 238 request, apiuser, pullrequestid, repoid=Optional(None),
239 239 userid=Optional(OAttr('apiuser'))):
240 240 """
241 241 Merge the pull request specified by `pullrequestid` into its target
242 242 repository.
243 243
244 244 :param apiuser: This is filled automatically from the |authtoken|.
245 245 :type apiuser: AuthUser
246 246 :param repoid: Optional, repository name or repository ID of the
247 247 target repository to which the |pr| is to be merged.
248 248 :type repoid: str or int
249 249 :param pullrequestid: ID of the pull request which shall be merged.
250 250 :type pullrequestid: int
251 251 :param userid: Merge the pull request as this user.
252 252 :type userid: Optional(str or int)
253 253
254 254 Example output:
255 255
256 256 .. code-block:: bash
257 257
258 258 "id": <id_given_in_input>,
259 259 "result": {
260 260 "executed": "<bool>",
261 261 "failure_reason": "<int>",
262 262 "merge_commit_id": "<merge_commit_id>",
263 263 "possible": "<bool>",
264 264 "merge_ref": {
265 265 "commit_id": "<commit_id>",
266 266 "type": "<type>",
267 267 "name": "<name>"
268 268 }
269 269 },
270 270 "error": null
271 271 """
272 272 pull_request = get_pull_request_or_error(pullrequestid)
273 273 if Optional.extract(repoid):
274 274 repo = get_repo_or_error(repoid)
275 275 else:
276 276 repo = pull_request.target_repo
277 277
278 278 if not isinstance(userid, Optional):
279 279 if (has_superadmin_permission(apiuser) or
280 280 HasRepoPermissionAnyApi('repository.admin')(
281 281 user=apiuser, repo_name=repo.repo_name)):
282 282 apiuser = get_user_or_error(userid)
283 283 else:
284 284 raise JSONRPCError('userid is not the same as your user')
285 285
286 286 check = MergeCheck.validate(
287 pull_request, user=apiuser, translator=request.translate)
287 pull_request, auth_user=apiuser, translator=request.translate)
288 288 merge_possible = not check.failed
289 289
290 290 if not merge_possible:
291 291 error_messages = []
292 292 for err_type, error_msg in check.errors:
293 293 error_msg = request.translate(error_msg)
294 294 error_messages.append(error_msg)
295 295
296 296 reasons = ','.join(error_messages)
297 297 raise JSONRPCError(
298 298 'merge not possible for following reasons: {}'.format(reasons))
299 299
300 300 target_repo = pull_request.target_repo
301 301 extras = vcs_operation_context(
302 302 request.environ, repo_name=target_repo.repo_name,
303 303 username=apiuser.username, action='push',
304 304 scm=target_repo.repo_type)
305 305 merge_response = PullRequestModel().merge_repo(
306 306 pull_request, apiuser, extras=extras)
307 307 if merge_response.executed:
308 308 PullRequestModel().close_pull_request(
309 309 pull_request.pull_request_id, apiuser)
310 310
311 311 Session().commit()
312 312
313 313 # In previous versions the merge response directly contained the merge
314 314 # commit id. It is now contained in the merge reference object. To be
315 315 # backwards compatible we have to extract it again.
316 316 merge_response = merge_response._asdict()
317 317 merge_response['merge_commit_id'] = merge_response['merge_ref'].commit_id
318 318
319 319 return merge_response
320 320
321 321
322 322 @jsonrpc_method()
323 323 def get_pull_request_comments(
324 324 request, apiuser, pullrequestid, repoid=Optional(None)):
325 325 """
326 326 Get all comments of pull request specified with the `pullrequestid`
327 327
328 328 :param apiuser: This is filled automatically from the |authtoken|.
329 329 :type apiuser: AuthUser
330 330 :param repoid: Optional repository name or repository ID.
331 331 :type repoid: str or int
332 332 :param pullrequestid: The pull request ID.
333 333 :type pullrequestid: int
334 334
335 335 Example output:
336 336
337 337 .. code-block:: bash
338 338
339 339 id : <id_given_in_input>
340 340 result : [
341 341 {
342 342 "comment_author": {
343 343 "active": true,
344 344 "full_name_or_username": "Tom Gore",
345 345 "username": "admin"
346 346 },
347 347 "comment_created_on": "2017-01-02T18:43:45.533",
348 348 "comment_f_path": null,
349 349 "comment_id": 25,
350 350 "comment_lineno": null,
351 351 "comment_status": {
352 352 "status": "under_review",
353 353 "status_lbl": "Under Review"
354 354 },
355 355 "comment_text": "Example text",
356 356 "comment_type": null,
357 357 "pull_request_version": null
358 358 }
359 359 ],
360 360 error : null
361 361 """
362 362
363 363 pull_request = get_pull_request_or_error(pullrequestid)
364 364 if Optional.extract(repoid):
365 365 repo = get_repo_or_error(repoid)
366 366 else:
367 367 repo = pull_request.target_repo
368 368
369 369 if not PullRequestModel().check_user_read(
370 370 pull_request, apiuser, api=True):
371 371 raise JSONRPCError('repository `%s` or pull request `%s` '
372 372 'does not exist' % (repoid, pullrequestid))
373 373
374 374 (pull_request_latest,
375 375 pull_request_at_ver,
376 376 pull_request_display_obj,
377 377 at_version) = PullRequestModel().get_pr_version(
378 378 pull_request.pull_request_id, version=None)
379 379
380 380 versions = pull_request_display_obj.versions()
381 381 ver_map = {
382 382 ver.pull_request_version_id: cnt
383 383 for cnt, ver in enumerate(versions, 1)
384 384 }
385 385
386 386 # GENERAL COMMENTS with versions #
387 387 q = CommentsModel()._all_general_comments_of_pull_request(pull_request)
388 388 q = q.order_by(ChangesetComment.comment_id.asc())
389 389 general_comments = q.all()
390 390
391 391 # INLINE COMMENTS with versions #
392 392 q = CommentsModel()._all_inline_comments_of_pull_request(pull_request)
393 393 q = q.order_by(ChangesetComment.comment_id.asc())
394 394 inline_comments = q.all()
395 395
396 396 data = []
397 397 for comment in inline_comments + general_comments:
398 398 full_data = comment.get_api_data()
399 399 pr_version_id = None
400 400 if comment.pull_request_version_id:
401 401 pr_version_id = 'v{}'.format(
402 402 ver_map[comment.pull_request_version_id])
403 403
404 404 # sanitize some entries
405 405
406 406 full_data['pull_request_version'] = pr_version_id
407 407 full_data['comment_author'] = {
408 408 'username': full_data['comment_author'].username,
409 409 'full_name_or_username': full_data['comment_author'].full_name_or_username,
410 410 'active': full_data['comment_author'].active,
411 411 }
412 412
413 413 if full_data['comment_status']:
414 414 full_data['comment_status'] = {
415 415 'status': full_data['comment_status'][0].status,
416 416 'status_lbl': full_data['comment_status'][0].status_lbl,
417 417 }
418 418 else:
419 419 full_data['comment_status'] = {}
420 420
421 421 data.append(full_data)
422 422 return data
423 423
424 424
425 425 @jsonrpc_method()
426 426 def comment_pull_request(
427 427 request, apiuser, pullrequestid, repoid=Optional(None),
428 428 message=Optional(None), commit_id=Optional(None), status=Optional(None),
429 429 comment_type=Optional(ChangesetComment.COMMENT_TYPE_NOTE),
430 430 resolves_comment_id=Optional(None),
431 431 userid=Optional(OAttr('apiuser'))):
432 432 """
433 433 Comment on the pull request specified with the `pullrequestid`,
434 434 in the |repo| specified by the `repoid`, and optionally change the
435 435 review status.
436 436
437 437 :param apiuser: This is filled automatically from the |authtoken|.
438 438 :type apiuser: AuthUser
439 439 :param repoid: Optional repository name or repository ID.
440 440 :type repoid: str or int
441 441 :param pullrequestid: The pull request ID.
442 442 :type pullrequestid: int
443 443 :param commit_id: Specify the commit_id for which to set a comment. If
444 444 given commit_id is different than latest in the PR status
445 445 change won't be performed.
446 446 :type commit_id: str
447 447 :param message: The text content of the comment.
448 448 :type message: str
449 449 :param status: (**Optional**) Set the approval status of the pull
450 450 request. One of: 'not_reviewed', 'approved', 'rejected',
451 451 'under_review'
452 452 :type status: str
453 453 :param comment_type: Comment type, one of: 'note', 'todo'
454 454 :type comment_type: Optional(str), default: 'note'
455 455 :param userid: Comment on the pull request as this user
456 456 :type userid: Optional(str or int)
457 457
458 458 Example output:
459 459
460 460 .. code-block:: bash
461 461
462 462 id : <id_given_in_input>
463 463 result : {
464 464 "pull_request_id": "<Integer>",
465 465 "comment_id": "<Integer>",
466 466 "status": {"given": <given_status>,
467 467 "was_changed": <bool status_was_actually_changed> },
468 468 },
469 469 error : null
470 470 """
471 471 pull_request = get_pull_request_or_error(pullrequestid)
472 472 if Optional.extract(repoid):
473 473 repo = get_repo_or_error(repoid)
474 474 else:
475 475 repo = pull_request.target_repo
476 476
477 477 if not isinstance(userid, Optional):
478 478 if (has_superadmin_permission(apiuser) or
479 479 HasRepoPermissionAnyApi('repository.admin')(
480 480 user=apiuser, repo_name=repo.repo_name)):
481 481 apiuser = get_user_or_error(userid)
482 482 else:
483 483 raise JSONRPCError('userid is not the same as your user')
484 484
485 485 if not PullRequestModel().check_user_read(
486 486 pull_request, apiuser, api=True):
487 487 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
488 488 message = Optional.extract(message)
489 489 status = Optional.extract(status)
490 490 commit_id = Optional.extract(commit_id)
491 491 comment_type = Optional.extract(comment_type)
492 492 resolves_comment_id = Optional.extract(resolves_comment_id)
493 493
494 494 if not message and not status:
495 495 raise JSONRPCError(
496 496 'Both message and status parameters are missing. '
497 497 'At least one is required.')
498 498
499 499 if (status not in (st[0] for st in ChangesetStatus.STATUSES) and
500 500 status is not None):
501 501 raise JSONRPCError('Unknown comment status: `%s`' % status)
502 502
503 503 if commit_id and commit_id not in pull_request.revisions:
504 504 raise JSONRPCError(
505 505 'Invalid commit_id `%s` for this pull request.' % commit_id)
506 506
507 507 allowed_to_change_status = PullRequestModel().check_user_change_status(
508 508 pull_request, apiuser)
509 509
510 510 # if commit_id is passed re-validated if user is allowed to change status
511 511 # based on latest commit_id from the PR
512 512 if commit_id:
513 513 commit_idx = pull_request.revisions.index(commit_id)
514 514 if commit_idx != 0:
515 515 allowed_to_change_status = False
516 516
517 517 if resolves_comment_id:
518 518 comment = ChangesetComment.get(resolves_comment_id)
519 519 if not comment:
520 520 raise JSONRPCError(
521 521 'Invalid resolves_comment_id `%s` for this pull request.'
522 522 % resolves_comment_id)
523 523 if comment.comment_type != ChangesetComment.COMMENT_TYPE_TODO:
524 524 raise JSONRPCError(
525 525 'Comment `%s` is wrong type for setting status to resolved.'
526 526 % resolves_comment_id)
527 527
528 528 text = message
529 529 status_label = ChangesetStatus.get_status_lbl(status)
530 530 if status and allowed_to_change_status:
531 531 st_message = ('Status change %(transition_icon)s %(status)s'
532 532 % {'transition_icon': '>', 'status': status_label})
533 533 text = message or st_message
534 534
535 535 rc_config = SettingsModel().get_all_settings()
536 536 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
537 537
538 538 status_change = status and allowed_to_change_status
539 539 comment = CommentsModel().create(
540 540 text=text,
541 541 repo=pull_request.target_repo.repo_id,
542 542 user=apiuser.user_id,
543 543 pull_request=pull_request.pull_request_id,
544 544 f_path=None,
545 545 line_no=None,
546 546 status_change=(status_label if status_change else None),
547 547 status_change_type=(status if status_change else None),
548 548 closing_pr=False,
549 549 renderer=renderer,
550 550 comment_type=comment_type,
551 551 resolves_comment_id=resolves_comment_id,
552 552 auth_user=apiuser
553 553 )
554 554
555 555 if allowed_to_change_status and status:
556 556 ChangesetStatusModel().set_status(
557 557 pull_request.target_repo.repo_id,
558 558 status,
559 559 apiuser.user_id,
560 560 comment,
561 561 pull_request=pull_request.pull_request_id
562 562 )
563 563 Session().flush()
564 564
565 565 Session().commit()
566 566 data = {
567 567 'pull_request_id': pull_request.pull_request_id,
568 568 'comment_id': comment.comment_id if comment else None,
569 569 'status': {'given': status, 'was_changed': status_change},
570 570 }
571 571 return data
572 572
573 573
574 574 @jsonrpc_method()
575 575 def create_pull_request(
576 576 request, apiuser, source_repo, target_repo, source_ref, target_ref,
577 577 title=Optional(''), description=Optional(''), description_renderer=Optional(''),
578 578 reviewers=Optional(None)):
579 579 """
580 580 Creates a new pull request.
581 581
582 582 Accepts refs in the following formats:
583 583
584 584 * branch:<branch_name>:<sha>
585 585 * branch:<branch_name>
586 586 * bookmark:<bookmark_name>:<sha> (Mercurial only)
587 587 * bookmark:<bookmark_name> (Mercurial only)
588 588
589 589 :param apiuser: This is filled automatically from the |authtoken|.
590 590 :type apiuser: AuthUser
591 591 :param source_repo: Set the source repository name.
592 592 :type source_repo: str
593 593 :param target_repo: Set the target repository name.
594 594 :type target_repo: str
595 595 :param source_ref: Set the source ref name.
596 596 :type source_ref: str
597 597 :param target_ref: Set the target ref name.
598 598 :type target_ref: str
599 599 :param title: Optionally Set the pull request title, it's generated otherwise
600 600 :type title: str
601 601 :param description: Set the pull request description.
602 602 :type description: Optional(str)
603 603 :type description_renderer: Optional(str)
604 604 :param description_renderer: Set pull request renderer for the description.
605 605 It should be 'rst', 'markdown' or 'plain'. If not give default
606 606 system renderer will be used
607 607 :param reviewers: Set the new pull request reviewers list.
608 608 Reviewer defined by review rules will be added automatically to the
609 609 defined list.
610 610 :type reviewers: Optional(list)
611 611 Accepts username strings or objects of the format:
612 612
613 613 [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}]
614 614 """
615 615
616 616 source_db_repo = get_repo_or_error(source_repo)
617 617 target_db_repo = get_repo_or_error(target_repo)
618 618 if not has_superadmin_permission(apiuser):
619 619 _perms = ('repository.admin', 'repository.write', 'repository.read',)
620 620 validate_repo_permissions(apiuser, source_repo, source_db_repo, _perms)
621 621
622 622 full_source_ref = resolve_ref_or_error(source_ref, source_db_repo)
623 623 full_target_ref = resolve_ref_or_error(target_ref, target_db_repo)
624 624
625 625 source_scm = source_db_repo.scm_instance()
626 626 target_scm = target_db_repo.scm_instance()
627 627
628 628 source_commit = get_commit_or_error(full_source_ref, source_db_repo)
629 629 target_commit = get_commit_or_error(full_target_ref, target_db_repo)
630 630
631 631 ancestor = source_scm.get_common_ancestor(
632 632 source_commit.raw_id, target_commit.raw_id, target_scm)
633 633 if not ancestor:
634 634 raise JSONRPCError('no common ancestor found')
635 635
636 636 # recalculate target ref based on ancestor
637 637 target_ref_type, target_ref_name, __ = full_target_ref.split(':')
638 638 full_target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
639 639
640 640 commit_ranges = target_scm.compare(
641 641 target_commit.raw_id, source_commit.raw_id, source_scm,
642 642 merge=True, pre_load=[])
643 643
644 644 if not commit_ranges:
645 645 raise JSONRPCError('no commits found')
646 646
647 647 reviewer_objects = Optional.extract(reviewers) or []
648 648
649 649 # serialize and validate passed in given reviewers
650 650 if reviewer_objects:
651 651 schema = ReviewerListSchema()
652 652 try:
653 653 reviewer_objects = schema.deserialize(reviewer_objects)
654 654 except Invalid as err:
655 655 raise JSONRPCValidationError(colander_exc=err)
656 656
657 657 # validate users
658 658 for reviewer_object in reviewer_objects:
659 659 user = get_user_or_error(reviewer_object['username'])
660 660 reviewer_object['user_id'] = user.user_id
661 661
662 662 get_default_reviewers_data, validate_default_reviewers = \
663 663 PullRequestModel().get_reviewer_functions()
664 664
665 665 # recalculate reviewers logic, to make sure we can validate this
666 666 reviewer_rules = get_default_reviewers_data(
667 667 apiuser.get_instance(), source_db_repo,
668 668 source_commit, target_db_repo, target_commit)
669 669
670 670 # now MERGE our given with the calculated
671 671 reviewer_objects = reviewer_rules['reviewers'] + reviewer_objects
672 672
673 673 try:
674 674 reviewers = validate_default_reviewers(
675 675 reviewer_objects, reviewer_rules)
676 676 except ValueError as e:
677 677 raise JSONRPCError('Reviewers Validation: {}'.format(e))
678 678
679 679 title = Optional.extract(title)
680 680 if not title:
681 681 title_source_ref = source_ref.split(':', 2)[1]
682 682 title = PullRequestModel().generate_pullrequest_title(
683 683 source=source_repo,
684 684 source_ref=title_source_ref,
685 685 target=target_repo
686 686 )
687 687 # fetch renderer, if set fallback to plain in case of PR
688 688 rc_config = SettingsModel().get_all_settings()
689 689 default_system_renderer = rc_config.get('rhodecode_markup_renderer', 'plain')
690 690 description = Optional.extract(description)
691 691 description_renderer = Optional.extract(description_renderer) or default_system_renderer
692 692
693 693 pull_request = PullRequestModel().create(
694 694 created_by=apiuser.user_id,
695 695 source_repo=source_repo,
696 696 source_ref=full_source_ref,
697 697 target_repo=target_repo,
698 698 target_ref=full_target_ref,
699 699 revisions=[commit.raw_id for commit in reversed(commit_ranges)],
700 700 reviewers=reviewers,
701 701 title=title,
702 702 description=description,
703 703 description_renderer=description_renderer,
704 704 reviewer_data=reviewer_rules,
705 705 auth_user=apiuser
706 706 )
707 707
708 708 Session().commit()
709 709 data = {
710 710 'msg': 'Created new pull request `{}`'.format(title),
711 711 'pull_request_id': pull_request.pull_request_id,
712 712 }
713 713 return data
714 714
715 715
716 716 @jsonrpc_method()
717 717 def update_pull_request(
718 718 request, apiuser, pullrequestid, repoid=Optional(None),
719 719 title=Optional(''), description=Optional(''), description_renderer=Optional(''),
720 720 reviewers=Optional(None), update_commits=Optional(None)):
721 721 """
722 722 Updates a pull request.
723 723
724 724 :param apiuser: This is filled automatically from the |authtoken|.
725 725 :type apiuser: AuthUser
726 726 :param repoid: Optional repository name or repository ID.
727 727 :type repoid: str or int
728 728 :param pullrequestid: The pull request ID.
729 729 :type pullrequestid: int
730 730 :param title: Set the pull request title.
731 731 :type title: str
732 732 :param description: Update pull request description.
733 733 :type description: Optional(str)
734 734 :type description_renderer: Optional(str)
735 735 :param description_renderer: Update pull request renderer for the description.
736 736 It should be 'rst', 'markdown' or 'plain'
737 737 :param reviewers: Update pull request reviewers list with new value.
738 738 :type reviewers: Optional(list)
739 739 Accepts username strings or objects of the format:
740 740
741 741 [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}]
742 742
743 743 :param update_commits: Trigger update of commits for this pull request
744 744 :type: update_commits: Optional(bool)
745 745
746 746 Example output:
747 747
748 748 .. code-block:: bash
749 749
750 750 id : <id_given_in_input>
751 751 result : {
752 752 "msg": "Updated pull request `63`",
753 753 "pull_request": <pull_request_object>,
754 754 "updated_reviewers": {
755 755 "added": [
756 756 "username"
757 757 ],
758 758 "removed": []
759 759 },
760 760 "updated_commits": {
761 761 "added": [
762 762 "<sha1_hash>"
763 763 ],
764 764 "common": [
765 765 "<sha1_hash>",
766 766 "<sha1_hash>",
767 767 ],
768 768 "removed": []
769 769 }
770 770 }
771 771 error : null
772 772 """
773 773
774 774 pull_request = get_pull_request_or_error(pullrequestid)
775 775 if Optional.extract(repoid):
776 776 repo = get_repo_or_error(repoid)
777 777 else:
778 778 repo = pull_request.target_repo
779 779
780 780 if not PullRequestModel().check_user_update(
781 781 pull_request, apiuser, api=True):
782 782 raise JSONRPCError(
783 783 'pull request `%s` update failed, no permission to update.' % (
784 784 pullrequestid,))
785 785 if pull_request.is_closed():
786 786 raise JSONRPCError(
787 787 'pull request `%s` update failed, pull request is closed' % (
788 788 pullrequestid,))
789 789
790 790 reviewer_objects = Optional.extract(reviewers) or []
791 791
792 792 if reviewer_objects:
793 793 schema = ReviewerListSchema()
794 794 try:
795 795 reviewer_objects = schema.deserialize(reviewer_objects)
796 796 except Invalid as err:
797 797 raise JSONRPCValidationError(colander_exc=err)
798 798
799 799 # validate users
800 800 for reviewer_object in reviewer_objects:
801 801 user = get_user_or_error(reviewer_object['username'])
802 802 reviewer_object['user_id'] = user.user_id
803 803
804 804 get_default_reviewers_data, get_validated_reviewers = \
805 805 PullRequestModel().get_reviewer_functions()
806 806
807 807 # re-use stored rules
808 808 reviewer_rules = pull_request.reviewer_data
809 809 try:
810 810 reviewers = get_validated_reviewers(
811 811 reviewer_objects, reviewer_rules)
812 812 except ValueError as e:
813 813 raise JSONRPCError('Reviewers Validation: {}'.format(e))
814 814 else:
815 815 reviewers = []
816 816
817 817 title = Optional.extract(title)
818 818 description = Optional.extract(description)
819 819 description_renderer = Optional.extract(description_renderer)
820 820
821 821 if title or description:
822 822 PullRequestModel().edit(
823 823 pull_request,
824 824 title or pull_request.title,
825 825 description or pull_request.description,
826 826 description_renderer or pull_request.description_renderer,
827 827 apiuser)
828 828 Session().commit()
829 829
830 830 commit_changes = {"added": [], "common": [], "removed": []}
831 831 if str2bool(Optional.extract(update_commits)):
832 832 if PullRequestModel().has_valid_update_type(pull_request):
833 833 update_response = PullRequestModel().update_commits(
834 834 pull_request)
835 835 commit_changes = update_response.changes or commit_changes
836 836 Session().commit()
837 837
838 838 reviewers_changes = {"added": [], "removed": []}
839 839 if reviewers:
840 840 added_reviewers, removed_reviewers = \
841 841 PullRequestModel().update_reviewers(pull_request, reviewers, apiuser)
842 842
843 843 reviewers_changes['added'] = sorted(
844 844 [get_user_or_error(n).username for n in added_reviewers])
845 845 reviewers_changes['removed'] = sorted(
846 846 [get_user_or_error(n).username for n in removed_reviewers])
847 847 Session().commit()
848 848
849 849 data = {
850 850 'msg': 'Updated pull request `{}`'.format(
851 851 pull_request.pull_request_id),
852 852 'pull_request': pull_request.get_api_data(),
853 853 'updated_commits': commit_changes,
854 854 'updated_reviewers': reviewers_changes
855 855 }
856 856
857 857 return data
858 858
859 859
860 860 @jsonrpc_method()
861 861 def close_pull_request(
862 862 request, apiuser, pullrequestid, repoid=Optional(None),
863 863 userid=Optional(OAttr('apiuser')), message=Optional('')):
864 864 """
865 865 Close the pull request specified by `pullrequestid`.
866 866
867 867 :param apiuser: This is filled automatically from the |authtoken|.
868 868 :type apiuser: AuthUser
869 869 :param repoid: Repository name or repository ID to which the pull
870 870 request belongs.
871 871 :type repoid: str or int
872 872 :param pullrequestid: ID of the pull request to be closed.
873 873 :type pullrequestid: int
874 874 :param userid: Close the pull request as this user.
875 875 :type userid: Optional(str or int)
876 876 :param message: Optional message to close the Pull Request with. If not
877 877 specified it will be generated automatically.
878 878 :type message: Optional(str)
879 879
880 880 Example output:
881 881
882 882 .. code-block:: bash
883 883
884 884 "id": <id_given_in_input>,
885 885 "result": {
886 886 "pull_request_id": "<int>",
887 887 "close_status": "<str:status_lbl>,
888 888 "closed": "<bool>"
889 889 },
890 890 "error": null
891 891
892 892 """
893 893 _ = request.translate
894 894
895 895 pull_request = get_pull_request_or_error(pullrequestid)
896 896 if Optional.extract(repoid):
897 897 repo = get_repo_or_error(repoid)
898 898 else:
899 899 repo = pull_request.target_repo
900 900
901 901 if not isinstance(userid, Optional):
902 902 if (has_superadmin_permission(apiuser) or
903 903 HasRepoPermissionAnyApi('repository.admin')(
904 904 user=apiuser, repo_name=repo.repo_name)):
905 905 apiuser = get_user_or_error(userid)
906 906 else:
907 907 raise JSONRPCError('userid is not the same as your user')
908 908
909 909 if pull_request.is_closed():
910 910 raise JSONRPCError(
911 911 'pull request `%s` is already closed' % (pullrequestid,))
912 912
913 913 # only owner or admin or person with write permissions
914 914 allowed_to_close = PullRequestModel().check_user_update(
915 915 pull_request, apiuser, api=True)
916 916
917 917 if not allowed_to_close:
918 918 raise JSONRPCError(
919 919 'pull request `%s` close failed, no permission to close.' % (
920 920 pullrequestid,))
921 921
922 922 # message we're using to close the PR, else it's automatically generated
923 923 message = Optional.extract(message)
924 924
925 925 # finally close the PR, with proper message comment
926 926 comment, status = PullRequestModel().close_pull_request_with_comment(
927 927 pull_request, apiuser, repo, message=message)
928 928 status_lbl = ChangesetStatus.get_status_lbl(status)
929 929
930 930 Session().commit()
931 931
932 932 data = {
933 933 'pull_request_id': pull_request.pull_request_id,
934 934 'close_status': status_lbl,
935 935 'closed': True,
936 936 }
937 937 return data
@@ -1,1324 +1,1325 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import collections
23 23
24 24 import formencode
25 25 import formencode.htmlfill
26 26 import peppercorn
27 27 from pyramid.httpexceptions import (
28 28 HTTPFound, HTTPNotFound, HTTPForbidden, HTTPBadRequest)
29 29 from pyramid.view import view_config
30 30 from pyramid.renderers import render
31 31
32 32 from rhodecode import events
33 33 from rhodecode.apps._base import RepoAppView, DataGridAppView
34 34
35 35 from rhodecode.lib import helpers as h, diffs, codeblocks, channelstream
36 36 from rhodecode.lib.base import vcs_operation_context
37 37 from rhodecode.lib.diffs import load_cached_diff, cache_diff, diff_cache_exist
38 38 from rhodecode.lib.ext_json import json
39 39 from rhodecode.lib.auth import (
40 40 LoginRequired, HasRepoPermissionAny, HasRepoPermissionAnyDecorator,
41 41 NotAnonymous, CSRFRequired)
42 42 from rhodecode.lib.utils2 import str2bool, safe_str, safe_unicode
43 43 from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason
44 44 from rhodecode.lib.vcs.exceptions import (CommitDoesNotExistError,
45 45 RepositoryRequirementError, EmptyRepositoryError)
46 46 from rhodecode.model.changeset_status import ChangesetStatusModel
47 47 from rhodecode.model.comment import CommentsModel
48 48 from rhodecode.model.db import (func, or_, PullRequest, PullRequestVersion,
49 49 ChangesetComment, ChangesetStatus, Repository)
50 50 from rhodecode.model.forms import PullRequestForm
51 51 from rhodecode.model.meta import Session
52 52 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
53 53 from rhodecode.model.scm import ScmModel
54 54
55 55 log = logging.getLogger(__name__)
56 56
57 57
58 58 class RepoPullRequestsView(RepoAppView, DataGridAppView):
59 59
60 60 def load_default_context(self):
61 61 c = self._get_local_tmpl_context(include_app_defaults=True)
62 62 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
63 63 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
64 64 # backward compat., we use for OLD PRs a plain renderer
65 65 c.renderer = 'plain'
66 66 return c
67 67
68 68 def _get_pull_requests_list(
69 69 self, repo_name, source, filter_type, opened_by, statuses):
70 70
71 71 draw, start, limit = self._extract_chunk(self.request)
72 72 search_q, order_by, order_dir = self._extract_ordering(self.request)
73 73 _render = self.request.get_partial_renderer(
74 74 'rhodecode:templates/data_table/_dt_elements.mako')
75 75
76 76 # pagination
77 77
78 78 if filter_type == 'awaiting_review':
79 79 pull_requests = PullRequestModel().get_awaiting_review(
80 80 repo_name, source=source, opened_by=opened_by,
81 81 statuses=statuses, offset=start, length=limit,
82 82 order_by=order_by, order_dir=order_dir)
83 83 pull_requests_total_count = PullRequestModel().count_awaiting_review(
84 84 repo_name, source=source, statuses=statuses,
85 85 opened_by=opened_by)
86 86 elif filter_type == 'awaiting_my_review':
87 87 pull_requests = PullRequestModel().get_awaiting_my_review(
88 88 repo_name, source=source, opened_by=opened_by,
89 89 user_id=self._rhodecode_user.user_id, statuses=statuses,
90 90 offset=start, length=limit, order_by=order_by,
91 91 order_dir=order_dir)
92 92 pull_requests_total_count = PullRequestModel().count_awaiting_my_review(
93 93 repo_name, source=source, user_id=self._rhodecode_user.user_id,
94 94 statuses=statuses, opened_by=opened_by)
95 95 else:
96 96 pull_requests = PullRequestModel().get_all(
97 97 repo_name, source=source, opened_by=opened_by,
98 98 statuses=statuses, offset=start, length=limit,
99 99 order_by=order_by, order_dir=order_dir)
100 100 pull_requests_total_count = PullRequestModel().count_all(
101 101 repo_name, source=source, statuses=statuses,
102 102 opened_by=opened_by)
103 103
104 104 data = []
105 105 comments_model = CommentsModel()
106 106 for pr in pull_requests:
107 107 comments = comments_model.get_all_comments(
108 108 self.db_repo.repo_id, pull_request=pr)
109 109
110 110 data.append({
111 111 'name': _render('pullrequest_name',
112 112 pr.pull_request_id, pr.target_repo.repo_name),
113 113 'name_raw': pr.pull_request_id,
114 114 'status': _render('pullrequest_status',
115 115 pr.calculated_review_status()),
116 116 'title': _render(
117 117 'pullrequest_title', pr.title, pr.description),
118 118 'description': h.escape(pr.description),
119 119 'updated_on': _render('pullrequest_updated_on',
120 120 h.datetime_to_time(pr.updated_on)),
121 121 'updated_on_raw': h.datetime_to_time(pr.updated_on),
122 122 'created_on': _render('pullrequest_updated_on',
123 123 h.datetime_to_time(pr.created_on)),
124 124 'created_on_raw': h.datetime_to_time(pr.created_on),
125 125 'author': _render('pullrequest_author',
126 126 pr.author.full_contact, ),
127 127 'author_raw': pr.author.full_name,
128 128 'comments': _render('pullrequest_comments', len(comments)),
129 129 'comments_raw': len(comments),
130 130 'closed': pr.is_closed(),
131 131 })
132 132
133 133 data = ({
134 134 'draw': draw,
135 135 'data': data,
136 136 'recordsTotal': pull_requests_total_count,
137 137 'recordsFiltered': pull_requests_total_count,
138 138 })
139 139 return data
140 140
141 141 @LoginRequired()
142 142 @HasRepoPermissionAnyDecorator(
143 143 'repository.read', 'repository.write', 'repository.admin')
144 144 @view_config(
145 145 route_name='pullrequest_show_all', request_method='GET',
146 146 renderer='rhodecode:templates/pullrequests/pullrequests.mako')
147 147 def pull_request_list(self):
148 148 c = self.load_default_context()
149 149
150 150 req_get = self.request.GET
151 151 c.source = str2bool(req_get.get('source'))
152 152 c.closed = str2bool(req_get.get('closed'))
153 153 c.my = str2bool(req_get.get('my'))
154 154 c.awaiting_review = str2bool(req_get.get('awaiting_review'))
155 155 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
156 156
157 157 c.active = 'open'
158 158 if c.my:
159 159 c.active = 'my'
160 160 if c.closed:
161 161 c.active = 'closed'
162 162 if c.awaiting_review and not c.source:
163 163 c.active = 'awaiting'
164 164 if c.source and not c.awaiting_review:
165 165 c.active = 'source'
166 166 if c.awaiting_my_review:
167 167 c.active = 'awaiting_my'
168 168
169 169 return self._get_template_context(c)
170 170
171 171 @LoginRequired()
172 172 @HasRepoPermissionAnyDecorator(
173 173 'repository.read', 'repository.write', 'repository.admin')
174 174 @view_config(
175 175 route_name='pullrequest_show_all_data', request_method='GET',
176 176 renderer='json_ext', xhr=True)
177 177 def pull_request_list_data(self):
178 178 self.load_default_context()
179 179
180 180 # additional filters
181 181 req_get = self.request.GET
182 182 source = str2bool(req_get.get('source'))
183 183 closed = str2bool(req_get.get('closed'))
184 184 my = str2bool(req_get.get('my'))
185 185 awaiting_review = str2bool(req_get.get('awaiting_review'))
186 186 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
187 187
188 188 filter_type = 'awaiting_review' if awaiting_review \
189 189 else 'awaiting_my_review' if awaiting_my_review \
190 190 else None
191 191
192 192 opened_by = None
193 193 if my:
194 194 opened_by = [self._rhodecode_user.user_id]
195 195
196 196 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
197 197 if closed:
198 198 statuses = [PullRequest.STATUS_CLOSED]
199 199
200 200 data = self._get_pull_requests_list(
201 201 repo_name=self.db_repo_name, source=source,
202 202 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
203 203
204 204 return data
205 205
206 206 def _is_diff_cache_enabled(self, target_repo):
207 207 caching_enabled = self._get_general_setting(
208 208 target_repo, 'rhodecode_diff_cache')
209 209 log.debug('Diff caching enabled: %s', caching_enabled)
210 210 return caching_enabled
211 211
212 212 def _get_diffset(self, source_repo_name, source_repo,
213 213 source_ref_id, target_ref_id,
214 214 target_commit, source_commit, diff_limit, file_limit,
215 215 fulldiff):
216 216
217 217 vcs_diff = PullRequestModel().get_diff(
218 218 source_repo, source_ref_id, target_ref_id)
219 219
220 220 diff_processor = diffs.DiffProcessor(
221 221 vcs_diff, format='newdiff', diff_limit=diff_limit,
222 222 file_limit=file_limit, show_full_diff=fulldiff)
223 223
224 224 _parsed = diff_processor.prepare()
225 225
226 226 diffset = codeblocks.DiffSet(
227 227 repo_name=self.db_repo_name,
228 228 source_repo_name=source_repo_name,
229 229 source_node_getter=codeblocks.diffset_node_getter(target_commit),
230 230 target_node_getter=codeblocks.diffset_node_getter(source_commit),
231 231 )
232 232 diffset = self.path_filter.render_patchset_filtered(
233 233 diffset, _parsed, target_commit.raw_id, source_commit.raw_id)
234 234
235 235 return diffset
236 236
237 237 @LoginRequired()
238 238 @HasRepoPermissionAnyDecorator(
239 239 'repository.read', 'repository.write', 'repository.admin')
240 240 @view_config(
241 241 route_name='pullrequest_show', request_method='GET',
242 242 renderer='rhodecode:templates/pullrequests/pullrequest_show.mako')
243 243 def pull_request_show(self):
244 244 pull_request_id = self.request.matchdict['pull_request_id']
245 245
246 246 c = self.load_default_context()
247 247
248 248 version = self.request.GET.get('version')
249 249 from_version = self.request.GET.get('from_version') or version
250 250 merge_checks = self.request.GET.get('merge_checks')
251 251 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
252 252 force_refresh = str2bool(self.request.GET.get('force_refresh'))
253 253
254 254 (pull_request_latest,
255 255 pull_request_at_ver,
256 256 pull_request_display_obj,
257 257 at_version) = PullRequestModel().get_pr_version(
258 258 pull_request_id, version=version)
259 259 pr_closed = pull_request_latest.is_closed()
260 260
261 261 if pr_closed and (version or from_version):
262 262 # not allow to browse versions
263 263 raise HTTPFound(h.route_path(
264 264 'pullrequest_show', repo_name=self.db_repo_name,
265 265 pull_request_id=pull_request_id))
266 266
267 267 versions = pull_request_display_obj.versions()
268 268
269 269 c.at_version = at_version
270 270 c.at_version_num = (at_version
271 271 if at_version and at_version != 'latest'
272 272 else None)
273 273 c.at_version_pos = ChangesetComment.get_index_from_version(
274 274 c.at_version_num, versions)
275 275
276 276 (prev_pull_request_latest,
277 277 prev_pull_request_at_ver,
278 278 prev_pull_request_display_obj,
279 279 prev_at_version) = PullRequestModel().get_pr_version(
280 280 pull_request_id, version=from_version)
281 281
282 282 c.from_version = prev_at_version
283 283 c.from_version_num = (prev_at_version
284 284 if prev_at_version and prev_at_version != 'latest'
285 285 else None)
286 286 c.from_version_pos = ChangesetComment.get_index_from_version(
287 287 c.from_version_num, versions)
288 288
289 289 # define if we're in COMPARE mode or VIEW at version mode
290 290 compare = at_version != prev_at_version
291 291
292 292 # pull_requests repo_name we opened it against
293 293 # ie. target_repo must match
294 294 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
295 295 raise HTTPNotFound()
296 296
297 297 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
298 298 pull_request_at_ver)
299 299
300 300 c.pull_request = pull_request_display_obj
301 301 c.renderer = pull_request_at_ver.description_renderer or c.renderer
302 302 c.pull_request_latest = pull_request_latest
303 303
304 304 if compare or (at_version and not at_version == 'latest'):
305 305 c.allowed_to_change_status = False
306 306 c.allowed_to_update = False
307 307 c.allowed_to_merge = False
308 308 c.allowed_to_delete = False
309 309 c.allowed_to_comment = False
310 310 c.allowed_to_close = False
311 311 else:
312 312 can_change_status = PullRequestModel().check_user_change_status(
313 313 pull_request_at_ver, self._rhodecode_user)
314 314 c.allowed_to_change_status = can_change_status and not pr_closed
315 315
316 316 c.allowed_to_update = PullRequestModel().check_user_update(
317 317 pull_request_latest, self._rhodecode_user) and not pr_closed
318 318 c.allowed_to_merge = PullRequestModel().check_user_merge(
319 319 pull_request_latest, self._rhodecode_user) and not pr_closed
320 320 c.allowed_to_delete = PullRequestModel().check_user_delete(
321 321 pull_request_latest, self._rhodecode_user) and not pr_closed
322 322 c.allowed_to_comment = not pr_closed
323 323 c.allowed_to_close = c.allowed_to_merge and not pr_closed
324 324
325 325 c.forbid_adding_reviewers = False
326 326 c.forbid_author_to_review = False
327 327 c.forbid_commit_author_to_review = False
328 328
329 329 if pull_request_latest.reviewer_data and \
330 330 'rules' in pull_request_latest.reviewer_data:
331 331 rules = pull_request_latest.reviewer_data['rules'] or {}
332 332 try:
333 333 c.forbid_adding_reviewers = rules.get(
334 334 'forbid_adding_reviewers')
335 335 c.forbid_author_to_review = rules.get(
336 336 'forbid_author_to_review')
337 337 c.forbid_commit_author_to_review = rules.get(
338 338 'forbid_commit_author_to_review')
339 339 except Exception:
340 340 pass
341 341
342 342 # check merge capabilities
343 343 _merge_check = MergeCheck.validate(
344 pull_request_latest, user=self._rhodecode_user,
344 pull_request_latest, auth_user=self._rhodecode_user,
345 345 translator=self.request.translate,
346 346 force_shadow_repo_refresh=force_refresh)
347 347 c.pr_merge_errors = _merge_check.error_details
348 348 c.pr_merge_possible = not _merge_check.failed
349 349 c.pr_merge_message = _merge_check.merge_msg
350 350
351 351 c.pr_merge_info = MergeCheck.get_merge_conditions(
352 352 pull_request_latest, translator=self.request.translate)
353 353
354 354 c.pull_request_review_status = _merge_check.review_status
355 355 if merge_checks:
356 356 self.request.override_renderer = \
357 357 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
358 358 return self._get_template_context(c)
359 359
360 360 comments_model = CommentsModel()
361 361
362 362 # reviewers and statuses
363 363 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
364 364 allowed_reviewers = [x[0].user_id for x in c.pull_request_reviewers]
365 365
366 366 # GENERAL COMMENTS with versions #
367 367 q = comments_model._all_general_comments_of_pull_request(pull_request_latest)
368 368 q = q.order_by(ChangesetComment.comment_id.asc())
369 369 general_comments = q
370 370
371 371 # pick comments we want to render at current version
372 372 c.comment_versions = comments_model.aggregate_comments(
373 373 general_comments, versions, c.at_version_num)
374 374 c.comments = c.comment_versions[c.at_version_num]['until']
375 375
376 376 # INLINE COMMENTS with versions #
377 377 q = comments_model._all_inline_comments_of_pull_request(pull_request_latest)
378 378 q = q.order_by(ChangesetComment.comment_id.asc())
379 379 inline_comments = q
380 380
381 381 c.inline_versions = comments_model.aggregate_comments(
382 382 inline_comments, versions, c.at_version_num, inline=True)
383 383
384 384 # inject latest version
385 385 latest_ver = PullRequest.get_pr_display_object(
386 386 pull_request_latest, pull_request_latest)
387 387
388 388 c.versions = versions + [latest_ver]
389 389
390 390 # if we use version, then do not show later comments
391 391 # than current version
392 392 display_inline_comments = collections.defaultdict(
393 393 lambda: collections.defaultdict(list))
394 394 for co in inline_comments:
395 395 if c.at_version_num:
396 396 # pick comments that are at least UPTO given version, so we
397 397 # don't render comments for higher version
398 398 should_render = co.pull_request_version_id and \
399 399 co.pull_request_version_id <= c.at_version_num
400 400 else:
401 401 # showing all, for 'latest'
402 402 should_render = True
403 403
404 404 if should_render:
405 405 display_inline_comments[co.f_path][co.line_no].append(co)
406 406
407 407 # load diff data into template context, if we use compare mode then
408 408 # diff is calculated based on changes between versions of PR
409 409
410 410 source_repo = pull_request_at_ver.source_repo
411 411 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
412 412
413 413 target_repo = pull_request_at_ver.target_repo
414 414 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
415 415
416 416 if compare:
417 417 # in compare switch the diff base to latest commit from prev version
418 418 target_ref_id = prev_pull_request_display_obj.revisions[0]
419 419
420 420 # despite opening commits for bookmarks/branches/tags, we always
421 421 # convert this to rev to prevent changes after bookmark or branch change
422 422 c.source_ref_type = 'rev'
423 423 c.source_ref = source_ref_id
424 424
425 425 c.target_ref_type = 'rev'
426 426 c.target_ref = target_ref_id
427 427
428 428 c.source_repo = source_repo
429 429 c.target_repo = target_repo
430 430
431 431 c.commit_ranges = []
432 432 source_commit = EmptyCommit()
433 433 target_commit = EmptyCommit()
434 434 c.missing_requirements = False
435 435
436 436 source_scm = source_repo.scm_instance()
437 437 target_scm = target_repo.scm_instance()
438 438
439 439 shadow_scm = None
440 440 try:
441 441 shadow_scm = pull_request_latest.get_shadow_repo()
442 442 except Exception:
443 443 log.debug('Failed to get shadow repo', exc_info=True)
444 444 # try first the existing source_repo, and then shadow
445 445 # repo if we can obtain one
446 446 commits_source_repo = source_scm or shadow_scm
447 447
448 448 c.commits_source_repo = commits_source_repo
449 449 c.ancestor = None # set it to None, to hide it from PR view
450 450
451 451 # empty version means latest, so we keep this to prevent
452 452 # double caching
453 453 version_normalized = version or 'latest'
454 454 from_version_normalized = from_version or 'latest'
455 455
456 456 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(
457 457 target_repo)
458 458 cache_file_path = diff_cache_exist(
459 459 cache_path, 'pull_request', pull_request_id, version_normalized,
460 460 from_version_normalized, source_ref_id, target_ref_id, c.fulldiff)
461 461
462 462 caching_enabled = self._is_diff_cache_enabled(c.target_repo)
463 463 force_recache = str2bool(self.request.GET.get('force_recache'))
464 464
465 465 cached_diff = None
466 466 if caching_enabled:
467 467 cached_diff = load_cached_diff(cache_file_path)
468 468
469 469 has_proper_commit_cache = (
470 470 cached_diff and cached_diff.get('commits')
471 471 and len(cached_diff.get('commits', [])) == 5
472 472 and cached_diff.get('commits')[0]
473 473 and cached_diff.get('commits')[3])
474 474 if not force_recache and has_proper_commit_cache:
475 475 diff_commit_cache = \
476 476 (ancestor_commit, commit_cache, missing_requirements,
477 477 source_commit, target_commit) = cached_diff['commits']
478 478 else:
479 479 diff_commit_cache = \
480 480 (ancestor_commit, commit_cache, missing_requirements,
481 481 source_commit, target_commit) = self.get_commits(
482 482 commits_source_repo,
483 483 pull_request_at_ver,
484 484 source_commit,
485 485 source_ref_id,
486 486 source_scm,
487 487 target_commit,
488 488 target_ref_id,
489 489 target_scm)
490 490
491 491 # register our commit range
492 492 for comm in commit_cache.values():
493 493 c.commit_ranges.append(comm)
494 494
495 495 c.missing_requirements = missing_requirements
496 496 c.ancestor_commit = ancestor_commit
497 497 c.statuses = source_repo.statuses(
498 498 [x.raw_id for x in c.commit_ranges])
499 499
500 500 # auto collapse if we have more than limit
501 501 collapse_limit = diffs.DiffProcessor._collapse_commits_over
502 502 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
503 503 c.compare_mode = compare
504 504
505 505 # diff_limit is the old behavior, will cut off the whole diff
506 506 # if the limit is applied otherwise will just hide the
507 507 # big files from the front-end
508 508 diff_limit = c.visual.cut_off_limit_diff
509 509 file_limit = c.visual.cut_off_limit_file
510 510
511 511 c.missing_commits = False
512 512 if (c.missing_requirements
513 513 or isinstance(source_commit, EmptyCommit)
514 514 or source_commit == target_commit):
515 515
516 516 c.missing_commits = True
517 517 else:
518 518 c.inline_comments = display_inline_comments
519 519
520 520 has_proper_diff_cache = cached_diff and cached_diff.get('commits')
521 521 if not force_recache and has_proper_diff_cache:
522 522 c.diffset = cached_diff['diff']
523 523 (ancestor_commit, commit_cache, missing_requirements,
524 524 source_commit, target_commit) = cached_diff['commits']
525 525 else:
526 526 c.diffset = self._get_diffset(
527 527 c.source_repo.repo_name, commits_source_repo,
528 528 source_ref_id, target_ref_id,
529 529 target_commit, source_commit,
530 530 diff_limit, file_limit, c.fulldiff)
531 531
532 532 # save cached diff
533 533 if caching_enabled:
534 534 cache_diff(cache_file_path, c.diffset, diff_commit_cache)
535 535
536 536 c.limited_diff = c.diffset.limited_diff
537 537
538 538 # calculate removed files that are bound to comments
539 539 comment_deleted_files = [
540 540 fname for fname in display_inline_comments
541 541 if fname not in c.diffset.file_stats]
542 542
543 543 c.deleted_files_comments = collections.defaultdict(dict)
544 544 for fname, per_line_comments in display_inline_comments.items():
545 545 if fname in comment_deleted_files:
546 546 c.deleted_files_comments[fname]['stats'] = 0
547 547 c.deleted_files_comments[fname]['comments'] = list()
548 548 for lno, comments in per_line_comments.items():
549 549 c.deleted_files_comments[fname]['comments'].extend(
550 550 comments)
551 551
552 552 # this is a hack to properly display links, when creating PR, the
553 553 # compare view and others uses different notation, and
554 554 # compare_commits.mako renders links based on the target_repo.
555 555 # We need to swap that here to generate it properly on the html side
556 556 c.target_repo = c.source_repo
557 557
558 558 c.commit_statuses = ChangesetStatus.STATUSES
559 559
560 560 c.show_version_changes = not pr_closed
561 561 if c.show_version_changes:
562 562 cur_obj = pull_request_at_ver
563 563 prev_obj = prev_pull_request_at_ver
564 564
565 565 old_commit_ids = prev_obj.revisions
566 566 new_commit_ids = cur_obj.revisions
567 567 commit_changes = PullRequestModel()._calculate_commit_id_changes(
568 568 old_commit_ids, new_commit_ids)
569 569 c.commit_changes_summary = commit_changes
570 570
571 571 # calculate the diff for commits between versions
572 572 c.commit_changes = []
573 573 mark = lambda cs, fw: list(
574 574 h.itertools.izip_longest([], cs, fillvalue=fw))
575 575 for c_type, raw_id in mark(commit_changes.added, 'a') \
576 576 + mark(commit_changes.removed, 'r') \
577 577 + mark(commit_changes.common, 'c'):
578 578
579 579 if raw_id in commit_cache:
580 580 commit = commit_cache[raw_id]
581 581 else:
582 582 try:
583 583 commit = commits_source_repo.get_commit(raw_id)
584 584 except CommitDoesNotExistError:
585 585 # in case we fail extracting still use "dummy" commit
586 586 # for display in commit diff
587 587 commit = h.AttributeDict(
588 588 {'raw_id': raw_id,
589 589 'message': 'EMPTY or MISSING COMMIT'})
590 590 c.commit_changes.append([c_type, commit])
591 591
592 592 # current user review statuses for each version
593 593 c.review_versions = {}
594 594 if self._rhodecode_user.user_id in allowed_reviewers:
595 595 for co in general_comments:
596 596 if co.author.user_id == self._rhodecode_user.user_id:
597 597 status = co.status_change
598 598 if status:
599 599 _ver_pr = status[0].comment.pull_request_version_id
600 600 c.review_versions[_ver_pr] = status[0]
601 601
602 602 return self._get_template_context(c)
603 603
604 604 def get_commits(
605 605 self, commits_source_repo, pull_request_at_ver, source_commit,
606 606 source_ref_id, source_scm, target_commit, target_ref_id, target_scm):
607 607 commit_cache = collections.OrderedDict()
608 608 missing_requirements = False
609 609 try:
610 610 pre_load = ["author", "branch", "date", "message"]
611 611 show_revs = pull_request_at_ver.revisions
612 612 for rev in show_revs:
613 613 comm = commits_source_repo.get_commit(
614 614 commit_id=rev, pre_load=pre_load)
615 615 commit_cache[comm.raw_id] = comm
616 616
617 617 # Order here matters, we first need to get target, and then
618 618 # the source
619 619 target_commit = commits_source_repo.get_commit(
620 620 commit_id=safe_str(target_ref_id))
621 621
622 622 source_commit = commits_source_repo.get_commit(
623 623 commit_id=safe_str(source_ref_id))
624 624 except CommitDoesNotExistError:
625 625 log.warning(
626 626 'Failed to get commit from `{}` repo'.format(
627 627 commits_source_repo), exc_info=True)
628 628 except RepositoryRequirementError:
629 629 log.warning(
630 630 'Failed to get all required data from repo', exc_info=True)
631 631 missing_requirements = True
632 632 ancestor_commit = None
633 633 try:
634 634 ancestor_id = source_scm.get_common_ancestor(
635 635 source_commit.raw_id, target_commit.raw_id, target_scm)
636 636 ancestor_commit = source_scm.get_commit(ancestor_id)
637 637 except Exception:
638 638 ancestor_commit = None
639 639 return ancestor_commit, commit_cache, missing_requirements, source_commit, target_commit
640 640
641 641 def assure_not_empty_repo(self):
642 642 _ = self.request.translate
643 643
644 644 try:
645 645 self.db_repo.scm_instance().get_commit()
646 646 except EmptyRepositoryError:
647 647 h.flash(h.literal(_('There are no commits yet')),
648 648 category='warning')
649 649 raise HTTPFound(
650 650 h.route_path('repo_summary', repo_name=self.db_repo.repo_name))
651 651
652 652 @LoginRequired()
653 653 @NotAnonymous()
654 654 @HasRepoPermissionAnyDecorator(
655 655 'repository.read', 'repository.write', 'repository.admin')
656 656 @view_config(
657 657 route_name='pullrequest_new', request_method='GET',
658 658 renderer='rhodecode:templates/pullrequests/pullrequest.mako')
659 659 def pull_request_new(self):
660 660 _ = self.request.translate
661 661 c = self.load_default_context()
662 662
663 663 self.assure_not_empty_repo()
664 664 source_repo = self.db_repo
665 665
666 666 commit_id = self.request.GET.get('commit')
667 667 branch_ref = self.request.GET.get('branch')
668 668 bookmark_ref = self.request.GET.get('bookmark')
669 669
670 670 try:
671 671 source_repo_data = PullRequestModel().generate_repo_data(
672 672 source_repo, commit_id=commit_id,
673 673 branch=branch_ref, bookmark=bookmark_ref,
674 674 translator=self.request.translate)
675 675 except CommitDoesNotExistError as e:
676 676 log.exception(e)
677 677 h.flash(_('Commit does not exist'), 'error')
678 678 raise HTTPFound(
679 679 h.route_path('pullrequest_new', repo_name=source_repo.repo_name))
680 680
681 681 default_target_repo = source_repo
682 682
683 683 if source_repo.parent:
684 684 parent_vcs_obj = source_repo.parent.scm_instance()
685 685 if parent_vcs_obj and not parent_vcs_obj.is_empty():
686 686 # change default if we have a parent repo
687 687 default_target_repo = source_repo.parent
688 688
689 689 target_repo_data = PullRequestModel().generate_repo_data(
690 690 default_target_repo, translator=self.request.translate)
691 691
692 692 selected_source_ref = source_repo_data['refs']['selected_ref']
693 693 title_source_ref = ''
694 694 if selected_source_ref:
695 695 title_source_ref = selected_source_ref.split(':', 2)[1]
696 696 c.default_title = PullRequestModel().generate_pullrequest_title(
697 697 source=source_repo.repo_name,
698 698 source_ref=title_source_ref,
699 699 target=default_target_repo.repo_name
700 700 )
701 701
702 702 c.default_repo_data = {
703 703 'source_repo_name': source_repo.repo_name,
704 704 'source_refs_json': json.dumps(source_repo_data),
705 705 'target_repo_name': default_target_repo.repo_name,
706 706 'target_refs_json': json.dumps(target_repo_data),
707 707 }
708 708 c.default_source_ref = selected_source_ref
709 709
710 710 return self._get_template_context(c)
711 711
712 712 @LoginRequired()
713 713 @NotAnonymous()
714 714 @HasRepoPermissionAnyDecorator(
715 715 'repository.read', 'repository.write', 'repository.admin')
716 716 @view_config(
717 717 route_name='pullrequest_repo_refs', request_method='GET',
718 718 renderer='json_ext', xhr=True)
719 719 def pull_request_repo_refs(self):
720 720 self.load_default_context()
721 721 target_repo_name = self.request.matchdict['target_repo_name']
722 722 repo = Repository.get_by_repo_name(target_repo_name)
723 723 if not repo:
724 724 raise HTTPNotFound()
725 725
726 726 target_perm = HasRepoPermissionAny(
727 727 'repository.read', 'repository.write', 'repository.admin')(
728 728 target_repo_name)
729 729 if not target_perm:
730 730 raise HTTPNotFound()
731 731
732 732 return PullRequestModel().generate_repo_data(
733 733 repo, translator=self.request.translate)
734 734
735 735 @LoginRequired()
736 736 @NotAnonymous()
737 737 @HasRepoPermissionAnyDecorator(
738 738 'repository.read', 'repository.write', 'repository.admin')
739 739 @view_config(
740 740 route_name='pullrequest_repo_destinations', request_method='GET',
741 741 renderer='json_ext', xhr=True)
742 742 def pull_request_repo_destinations(self):
743 743 _ = self.request.translate
744 744 filter_query = self.request.GET.get('query')
745 745
746 746 query = Repository.query() \
747 747 .order_by(func.length(Repository.repo_name)) \
748 748 .filter(
749 749 or_(Repository.repo_name == self.db_repo.repo_name,
750 750 Repository.fork_id == self.db_repo.repo_id))
751 751
752 752 if filter_query:
753 753 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
754 754 query = query.filter(
755 755 Repository.repo_name.ilike(ilike_expression))
756 756
757 757 add_parent = False
758 758 if self.db_repo.parent:
759 759 if filter_query in self.db_repo.parent.repo_name:
760 760 parent_vcs_obj = self.db_repo.parent.scm_instance()
761 761 if parent_vcs_obj and not parent_vcs_obj.is_empty():
762 762 add_parent = True
763 763
764 764 limit = 20 - 1 if add_parent else 20
765 765 all_repos = query.limit(limit).all()
766 766 if add_parent:
767 767 all_repos += [self.db_repo.parent]
768 768
769 769 repos = []
770 770 for obj in ScmModel().get_repos(all_repos):
771 771 repos.append({
772 772 'id': obj['name'],
773 773 'text': obj['name'],
774 774 'type': 'repo',
775 775 'repo_id': obj['dbrepo']['repo_id'],
776 776 'repo_type': obj['dbrepo']['repo_type'],
777 777 'private': obj['dbrepo']['private'],
778 778
779 779 })
780 780
781 781 data = {
782 782 'more': False,
783 783 'results': [{
784 784 'text': _('Repositories'),
785 785 'children': repos
786 786 }] if repos else []
787 787 }
788 788 return data
789 789
790 790 @LoginRequired()
791 791 @NotAnonymous()
792 792 @HasRepoPermissionAnyDecorator(
793 793 'repository.read', 'repository.write', 'repository.admin')
794 794 @CSRFRequired()
795 795 @view_config(
796 796 route_name='pullrequest_create', request_method='POST',
797 797 renderer=None)
798 798 def pull_request_create(self):
799 799 _ = self.request.translate
800 800 self.assure_not_empty_repo()
801 801 self.load_default_context()
802 802
803 803 controls = peppercorn.parse(self.request.POST.items())
804 804
805 805 try:
806 806 form = PullRequestForm(
807 807 self.request.translate, self.db_repo.repo_id)()
808 808 _form = form.to_python(controls)
809 809 except formencode.Invalid as errors:
810 810 if errors.error_dict.get('revisions'):
811 811 msg = 'Revisions: %s' % errors.error_dict['revisions']
812 812 elif errors.error_dict.get('pullrequest_title'):
813 813 msg = errors.error_dict.get('pullrequest_title')
814 814 else:
815 815 msg = _('Error creating pull request: {}').format(errors)
816 816 log.exception(msg)
817 817 h.flash(msg, 'error')
818 818
819 819 # would rather just go back to form ...
820 820 raise HTTPFound(
821 821 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
822 822
823 823 source_repo = _form['source_repo']
824 824 source_ref = _form['source_ref']
825 825 target_repo = _form['target_repo']
826 826 target_ref = _form['target_ref']
827 827 commit_ids = _form['revisions'][::-1]
828 828
829 829 # find the ancestor for this pr
830 830 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
831 831 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
832 832
833 833 # re-check permissions again here
834 834 # source_repo we must have read permissions
835 835
836 836 source_perm = HasRepoPermissionAny(
837 837 'repository.read',
838 838 'repository.write', 'repository.admin')(source_db_repo.repo_name)
839 839 if not source_perm:
840 840 msg = _('Not Enough permissions to source repo `{}`.'.format(
841 841 source_db_repo.repo_name))
842 842 h.flash(msg, category='error')
843 843 # copy the args back to redirect
844 844 org_query = self.request.GET.mixed()
845 845 raise HTTPFound(
846 846 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
847 847 _query=org_query))
848 848
849 849 # target repo we must have read permissions, and also later on
850 850 # we want to check branch permissions here
851 851 target_perm = HasRepoPermissionAny(
852 852 'repository.read',
853 853 'repository.write', 'repository.admin')(target_db_repo.repo_name)
854 854 if not target_perm:
855 855 msg = _('Not Enough permissions to target repo `{}`.'.format(
856 856 target_db_repo.repo_name))
857 857 h.flash(msg, category='error')
858 858 # copy the args back to redirect
859 859 org_query = self.request.GET.mixed()
860 860 raise HTTPFound(
861 861 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
862 862 _query=org_query))
863 863
864 864 source_scm = source_db_repo.scm_instance()
865 865 target_scm = target_db_repo.scm_instance()
866 866
867 867 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
868 868 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
869 869
870 870 ancestor = source_scm.get_common_ancestor(
871 871 source_commit.raw_id, target_commit.raw_id, target_scm)
872 872
873 873 # recalculate target ref based on ancestor
874 874 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
875 875 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
876 876
877 877 get_default_reviewers_data, validate_default_reviewers = \
878 878 PullRequestModel().get_reviewer_functions()
879 879
880 880 # recalculate reviewers logic, to make sure we can validate this
881 881 reviewer_rules = get_default_reviewers_data(
882 882 self._rhodecode_db_user, source_db_repo,
883 883 source_commit, target_db_repo, target_commit)
884 884
885 885 given_reviewers = _form['review_members']
886 886 reviewers = validate_default_reviewers(
887 887 given_reviewers, reviewer_rules)
888 888
889 889 pullrequest_title = _form['pullrequest_title']
890 890 title_source_ref = source_ref.split(':', 2)[1]
891 891 if not pullrequest_title:
892 892 pullrequest_title = PullRequestModel().generate_pullrequest_title(
893 893 source=source_repo,
894 894 source_ref=title_source_ref,
895 895 target=target_repo
896 896 )
897 897
898 898 description = _form['pullrequest_desc']
899 899 description_renderer = _form['description_renderer']
900 900
901 901 try:
902 902 pull_request = PullRequestModel().create(
903 903 created_by=self._rhodecode_user.user_id,
904 904 source_repo=source_repo,
905 905 source_ref=source_ref,
906 906 target_repo=target_repo,
907 907 target_ref=target_ref,
908 908 revisions=commit_ids,
909 909 reviewers=reviewers,
910 910 title=pullrequest_title,
911 911 description=description,
912 912 description_renderer=description_renderer,
913 913 reviewer_data=reviewer_rules,
914 914 auth_user=self._rhodecode_user
915 915 )
916 916 Session().commit()
917 917
918 918 h.flash(_('Successfully opened new pull request'),
919 919 category='success')
920 920 except Exception:
921 921 msg = _('Error occurred during creation of this pull request.')
922 922 log.exception(msg)
923 923 h.flash(msg, category='error')
924 924
925 925 # copy the args back to redirect
926 926 org_query = self.request.GET.mixed()
927 927 raise HTTPFound(
928 928 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
929 929 _query=org_query))
930 930
931 931 raise HTTPFound(
932 932 h.route_path('pullrequest_show', repo_name=target_repo,
933 933 pull_request_id=pull_request.pull_request_id))
934 934
935 935 @LoginRequired()
936 936 @NotAnonymous()
937 937 @HasRepoPermissionAnyDecorator(
938 938 'repository.read', 'repository.write', 'repository.admin')
939 939 @CSRFRequired()
940 940 @view_config(
941 941 route_name='pullrequest_update', request_method='POST',
942 942 renderer='json_ext')
943 943 def pull_request_update(self):
944 944 pull_request = PullRequest.get_or_404(
945 945 self.request.matchdict['pull_request_id'])
946 946 _ = self.request.translate
947 947
948 948 self.load_default_context()
949 949
950 950 if pull_request.is_closed():
951 951 log.debug('update: forbidden because pull request is closed')
952 952 msg = _(u'Cannot update closed pull requests.')
953 953 h.flash(msg, category='error')
954 954 return True
955 955
956 956 # only owner or admin can update it
957 957 allowed_to_update = PullRequestModel().check_user_update(
958 958 pull_request, self._rhodecode_user)
959 959 if allowed_to_update:
960 960 controls = peppercorn.parse(self.request.POST.items())
961 961
962 962 if 'review_members' in controls:
963 963 self._update_reviewers(
964 964 pull_request, controls['review_members'],
965 965 pull_request.reviewer_data)
966 966 elif str2bool(self.request.POST.get('update_commits', 'false')):
967 967 self._update_commits(pull_request)
968 968 elif str2bool(self.request.POST.get('edit_pull_request', 'false')):
969 969 self._edit_pull_request(pull_request)
970 970 else:
971 971 raise HTTPBadRequest()
972 972 return True
973 973 raise HTTPForbidden()
974 974
975 975 def _edit_pull_request(self, pull_request):
976 976 _ = self.request.translate
977 977
978 978 try:
979 979 PullRequestModel().edit(
980 980 pull_request,
981 981 self.request.POST.get('title'),
982 982 self.request.POST.get('description'),
983 983 self.request.POST.get('description_renderer'),
984 984 self._rhodecode_user)
985 985 except ValueError:
986 986 msg = _(u'Cannot update closed pull requests.')
987 987 h.flash(msg, category='error')
988 988 return
989 989 else:
990 990 Session().commit()
991 991
992 992 msg = _(u'Pull request title & description updated.')
993 993 h.flash(msg, category='success')
994 994 return
995 995
996 996 def _update_commits(self, pull_request):
997 997 _ = self.request.translate
998 998 resp = PullRequestModel().update_commits(pull_request)
999 999
1000 1000 if resp.executed:
1001 1001
1002 1002 if resp.target_changed and resp.source_changed:
1003 1003 changed = 'target and source repositories'
1004 1004 elif resp.target_changed and not resp.source_changed:
1005 1005 changed = 'target repository'
1006 1006 elif not resp.target_changed and resp.source_changed:
1007 1007 changed = 'source repository'
1008 1008 else:
1009 1009 changed = 'nothing'
1010 1010
1011 1011 msg = _(
1012 1012 u'Pull request updated to "{source_commit_id}" with '
1013 1013 u'{count_added} added, {count_removed} removed commits. '
1014 1014 u'Source of changes: {change_source}')
1015 1015 msg = msg.format(
1016 1016 source_commit_id=pull_request.source_ref_parts.commit_id,
1017 1017 count_added=len(resp.changes.added),
1018 1018 count_removed=len(resp.changes.removed),
1019 1019 change_source=changed)
1020 1020 h.flash(msg, category='success')
1021 1021
1022 1022 channel = '/repo${}$/pr/{}'.format(
1023 1023 pull_request.target_repo.repo_name,
1024 1024 pull_request.pull_request_id)
1025 1025 message = msg + (
1026 1026 ' - <a onclick="window.location.reload()">'
1027 1027 '<strong>{}</strong></a>'.format(_('Reload page')))
1028 1028 channelstream.post_message(
1029 1029 channel, message, self._rhodecode_user.username,
1030 1030 registry=self.request.registry)
1031 1031 else:
1032 1032 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
1033 1033 warning_reasons = [
1034 1034 UpdateFailureReason.NO_CHANGE,
1035 1035 UpdateFailureReason.WRONG_REF_TYPE,
1036 1036 ]
1037 1037 category = 'warning' if resp.reason in warning_reasons else 'error'
1038 1038 h.flash(msg, category=category)
1039 1039
1040 1040 @LoginRequired()
1041 1041 @NotAnonymous()
1042 1042 @HasRepoPermissionAnyDecorator(
1043 1043 'repository.read', 'repository.write', 'repository.admin')
1044 1044 @CSRFRequired()
1045 1045 @view_config(
1046 1046 route_name='pullrequest_merge', request_method='POST',
1047 1047 renderer='json_ext')
1048 1048 def pull_request_merge(self):
1049 1049 """
1050 1050 Merge will perform a server-side merge of the specified
1051 1051 pull request, if the pull request is approved and mergeable.
1052 1052 After successful merging, the pull request is automatically
1053 1053 closed, with a relevant comment.
1054 1054 """
1055 1055 pull_request = PullRequest.get_or_404(
1056 1056 self.request.matchdict['pull_request_id'])
1057 1057
1058 1058 self.load_default_context()
1059 check = MergeCheck.validate(pull_request, self._rhodecode_db_user,
1060 translator=self.request.translate)
1059 check = MergeCheck.validate(
1060 pull_request, auth_user=self._rhodecode_user,
1061 translator=self.request.translate)
1061 1062 merge_possible = not check.failed
1062 1063
1063 1064 for err_type, error_msg in check.errors:
1064 1065 h.flash(error_msg, category=err_type)
1065 1066
1066 1067 if merge_possible:
1067 1068 log.debug("Pre-conditions checked, trying to merge.")
1068 1069 extras = vcs_operation_context(
1069 1070 self.request.environ, repo_name=pull_request.target_repo.repo_name,
1070 1071 username=self._rhodecode_db_user.username, action='push',
1071 1072 scm=pull_request.target_repo.repo_type)
1072 1073 self._merge_pull_request(
1073 1074 pull_request, self._rhodecode_db_user, extras)
1074 1075 else:
1075 1076 log.debug("Pre-conditions failed, NOT merging.")
1076 1077
1077 1078 raise HTTPFound(
1078 1079 h.route_path('pullrequest_show',
1079 1080 repo_name=pull_request.target_repo.repo_name,
1080 1081 pull_request_id=pull_request.pull_request_id))
1081 1082
1082 1083 def _merge_pull_request(self, pull_request, user, extras):
1083 1084 _ = self.request.translate
1084 1085 merge_resp = PullRequestModel().merge_repo(pull_request, user, extras=extras)
1085 1086
1086 1087 if merge_resp.executed:
1087 1088 log.debug("The merge was successful, closing the pull request.")
1088 1089 PullRequestModel().close_pull_request(
1089 1090 pull_request.pull_request_id, user)
1090 1091 Session().commit()
1091 1092 msg = _('Pull request was successfully merged and closed.')
1092 1093 h.flash(msg, category='success')
1093 1094 else:
1094 1095 log.debug(
1095 1096 "The merge was not successful. Merge response: %s",
1096 1097 merge_resp)
1097 1098 msg = PullRequestModel().merge_status_message(
1098 1099 merge_resp.failure_reason)
1099 1100 h.flash(msg, category='error')
1100 1101
1101 1102 def _update_reviewers(self, pull_request, review_members, reviewer_rules):
1102 1103 _ = self.request.translate
1103 1104 get_default_reviewers_data, validate_default_reviewers = \
1104 1105 PullRequestModel().get_reviewer_functions()
1105 1106
1106 1107 try:
1107 1108 reviewers = validate_default_reviewers(review_members, reviewer_rules)
1108 1109 except ValueError as e:
1109 1110 log.error('Reviewers Validation: {}'.format(e))
1110 1111 h.flash(e, category='error')
1111 1112 return
1112 1113
1113 1114 PullRequestModel().update_reviewers(
1114 1115 pull_request, reviewers, self._rhodecode_user)
1115 1116 h.flash(_('Pull request reviewers updated.'), category='success')
1116 1117 Session().commit()
1117 1118
1118 1119 @LoginRequired()
1119 1120 @NotAnonymous()
1120 1121 @HasRepoPermissionAnyDecorator(
1121 1122 'repository.read', 'repository.write', 'repository.admin')
1122 1123 @CSRFRequired()
1123 1124 @view_config(
1124 1125 route_name='pullrequest_delete', request_method='POST',
1125 1126 renderer='json_ext')
1126 1127 def pull_request_delete(self):
1127 1128 _ = self.request.translate
1128 1129
1129 1130 pull_request = PullRequest.get_or_404(
1130 1131 self.request.matchdict['pull_request_id'])
1131 1132 self.load_default_context()
1132 1133
1133 1134 pr_closed = pull_request.is_closed()
1134 1135 allowed_to_delete = PullRequestModel().check_user_delete(
1135 1136 pull_request, self._rhodecode_user) and not pr_closed
1136 1137
1137 1138 # only owner can delete it !
1138 1139 if allowed_to_delete:
1139 1140 PullRequestModel().delete(pull_request, self._rhodecode_user)
1140 1141 Session().commit()
1141 1142 h.flash(_('Successfully deleted pull request'),
1142 1143 category='success')
1143 1144 raise HTTPFound(h.route_path('pullrequest_show_all',
1144 1145 repo_name=self.db_repo_name))
1145 1146
1146 1147 log.warning('user %s tried to delete pull request without access',
1147 1148 self._rhodecode_user)
1148 1149 raise HTTPNotFound()
1149 1150
1150 1151 @LoginRequired()
1151 1152 @NotAnonymous()
1152 1153 @HasRepoPermissionAnyDecorator(
1153 1154 'repository.read', 'repository.write', 'repository.admin')
1154 1155 @CSRFRequired()
1155 1156 @view_config(
1156 1157 route_name='pullrequest_comment_create', request_method='POST',
1157 1158 renderer='json_ext')
1158 1159 def pull_request_comment_create(self):
1159 1160 _ = self.request.translate
1160 1161
1161 1162 pull_request = PullRequest.get_or_404(
1162 1163 self.request.matchdict['pull_request_id'])
1163 1164 pull_request_id = pull_request.pull_request_id
1164 1165
1165 1166 if pull_request.is_closed():
1166 1167 log.debug('comment: forbidden because pull request is closed')
1167 1168 raise HTTPForbidden()
1168 1169
1169 1170 allowed_to_comment = PullRequestModel().check_user_comment(
1170 1171 pull_request, self._rhodecode_user)
1171 1172 if not allowed_to_comment:
1172 1173 log.debug(
1173 1174 'comment: forbidden because pull request is from forbidden repo')
1174 1175 raise HTTPForbidden()
1175 1176
1176 1177 c = self.load_default_context()
1177 1178
1178 1179 status = self.request.POST.get('changeset_status', None)
1179 1180 text = self.request.POST.get('text')
1180 1181 comment_type = self.request.POST.get('comment_type')
1181 1182 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
1182 1183 close_pull_request = self.request.POST.get('close_pull_request')
1183 1184
1184 1185 # the logic here should work like following, if we submit close
1185 1186 # pr comment, use `close_pull_request_with_comment` function
1186 1187 # else handle regular comment logic
1187 1188
1188 1189 if close_pull_request:
1189 1190 # only owner or admin or person with write permissions
1190 1191 allowed_to_close = PullRequestModel().check_user_update(
1191 1192 pull_request, self._rhodecode_user)
1192 1193 if not allowed_to_close:
1193 1194 log.debug('comment: forbidden because not allowed to close '
1194 1195 'pull request %s', pull_request_id)
1195 1196 raise HTTPForbidden()
1196 1197 comment, status = PullRequestModel().close_pull_request_with_comment(
1197 1198 pull_request, self._rhodecode_user, self.db_repo, message=text)
1198 1199 Session().flush()
1199 1200 events.trigger(
1200 1201 events.PullRequestCommentEvent(pull_request, comment))
1201 1202
1202 1203 else:
1203 1204 # regular comment case, could be inline, or one with status.
1204 1205 # for that one we check also permissions
1205 1206
1206 1207 allowed_to_change_status = PullRequestModel().check_user_change_status(
1207 1208 pull_request, self._rhodecode_user)
1208 1209
1209 1210 if status and allowed_to_change_status:
1210 1211 message = (_('Status change %(transition_icon)s %(status)s')
1211 1212 % {'transition_icon': '>',
1212 1213 'status': ChangesetStatus.get_status_lbl(status)})
1213 1214 text = text or message
1214 1215
1215 1216 comment = CommentsModel().create(
1216 1217 text=text,
1217 1218 repo=self.db_repo.repo_id,
1218 1219 user=self._rhodecode_user.user_id,
1219 1220 pull_request=pull_request,
1220 1221 f_path=self.request.POST.get('f_path'),
1221 1222 line_no=self.request.POST.get('line'),
1222 1223 status_change=(ChangesetStatus.get_status_lbl(status)
1223 1224 if status and allowed_to_change_status else None),
1224 1225 status_change_type=(status
1225 1226 if status and allowed_to_change_status else None),
1226 1227 comment_type=comment_type,
1227 1228 resolves_comment_id=resolves_comment_id,
1228 1229 auth_user=self._rhodecode_user
1229 1230 )
1230 1231
1231 1232 if allowed_to_change_status:
1232 1233 # calculate old status before we change it
1233 1234 old_calculated_status = pull_request.calculated_review_status()
1234 1235
1235 1236 # get status if set !
1236 1237 if status:
1237 1238 ChangesetStatusModel().set_status(
1238 1239 self.db_repo.repo_id,
1239 1240 status,
1240 1241 self._rhodecode_user.user_id,
1241 1242 comment,
1242 1243 pull_request=pull_request
1243 1244 )
1244 1245
1245 1246 Session().flush()
1246 1247 # this is somehow required to get access to some relationship
1247 1248 # loaded on comment
1248 1249 Session().refresh(comment)
1249 1250
1250 1251 events.trigger(
1251 1252 events.PullRequestCommentEvent(pull_request, comment))
1252 1253
1253 1254 # we now calculate the status of pull request, and based on that
1254 1255 # calculation we set the commits status
1255 1256 calculated_status = pull_request.calculated_review_status()
1256 1257 if old_calculated_status != calculated_status:
1257 1258 PullRequestModel()._trigger_pull_request_hook(
1258 1259 pull_request, self._rhodecode_user, 'review_status_change')
1259 1260
1260 1261 Session().commit()
1261 1262
1262 1263 data = {
1263 1264 'target_id': h.safeid(h.safe_unicode(
1264 1265 self.request.POST.get('f_path'))),
1265 1266 }
1266 1267 if comment:
1267 1268 c.co = comment
1268 1269 rendered_comment = render(
1269 1270 'rhodecode:templates/changeset/changeset_comment_block.mako',
1270 1271 self._get_template_context(c), self.request)
1271 1272
1272 1273 data.update(comment.get_dict())
1273 1274 data.update({'rendered_text': rendered_comment})
1274 1275
1275 1276 return data
1276 1277
1277 1278 @LoginRequired()
1278 1279 @NotAnonymous()
1279 1280 @HasRepoPermissionAnyDecorator(
1280 1281 'repository.read', 'repository.write', 'repository.admin')
1281 1282 @CSRFRequired()
1282 1283 @view_config(
1283 1284 route_name='pullrequest_comment_delete', request_method='POST',
1284 1285 renderer='json_ext')
1285 1286 def pull_request_comment_delete(self):
1286 1287 pull_request = PullRequest.get_or_404(
1287 1288 self.request.matchdict['pull_request_id'])
1288 1289
1289 1290 comment = ChangesetComment.get_or_404(
1290 1291 self.request.matchdict['comment_id'])
1291 1292 comment_id = comment.comment_id
1292 1293
1293 1294 if pull_request.is_closed():
1294 1295 log.debug('comment: forbidden because pull request is closed')
1295 1296 raise HTTPForbidden()
1296 1297
1297 1298 if not comment:
1298 1299 log.debug('Comment with id:%s not found, skipping', comment_id)
1299 1300 # comment already deleted in another call probably
1300 1301 return True
1301 1302
1302 1303 if comment.pull_request.is_closed():
1303 1304 # don't allow deleting comments on closed pull request
1304 1305 raise HTTPForbidden()
1305 1306
1306 1307 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1307 1308 super_admin = h.HasPermissionAny('hg.admin')()
1308 1309 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1309 1310 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1310 1311 comment_repo_admin = is_repo_admin and is_repo_comment
1311 1312
1312 1313 if super_admin or comment_owner or comment_repo_admin:
1313 1314 old_calculated_status = comment.pull_request.calculated_review_status()
1314 1315 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
1315 1316 Session().commit()
1316 1317 calculated_status = comment.pull_request.calculated_review_status()
1317 1318 if old_calculated_status != calculated_status:
1318 1319 PullRequestModel()._trigger_pull_request_hook(
1319 1320 comment.pull_request, self._rhodecode_user, 'review_status_change')
1320 1321 return True
1321 1322 else:
1322 1323 log.warning('No permissions for user %s to delete comment_id: %s',
1323 1324 self._rhodecode_db_user, comment_id)
1324 1325 raise HTTPNotFound()
@@ -1,1707 +1,1726 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 = 3
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 132
133 133 def __get_pull_request(self, pull_request):
134 134 return self._get_instance((
135 135 PullRequest, PullRequestVersion), pull_request)
136 136
137 137 def _check_perms(self, perms, pull_request, user, api=False):
138 138 if not api:
139 139 return h.HasRepoPermissionAny(*perms)(
140 140 user=user, repo_name=pull_request.target_repo.repo_name)
141 141 else:
142 142 return h.HasRepoPermissionAnyApi(*perms)(
143 143 user=user, repo_name=pull_request.target_repo.repo_name)
144 144
145 145 def check_user_read(self, pull_request, user, api=False):
146 146 _perms = ('repository.admin', 'repository.write', 'repository.read',)
147 147 return self._check_perms(_perms, pull_request, user, api)
148 148
149 149 def check_user_merge(self, pull_request, user, api=False):
150 150 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
151 151 return self._check_perms(_perms, pull_request, user, api)
152 152
153 153 def check_user_update(self, pull_request, user, api=False):
154 154 owner = user.user_id == pull_request.user_id
155 155 return self.check_user_merge(pull_request, user, api) or owner
156 156
157 157 def check_user_delete(self, pull_request, user):
158 158 owner = user.user_id == pull_request.user_id
159 159 _perms = ('repository.admin',)
160 160 return self._check_perms(_perms, pull_request, user) or owner
161 161
162 162 def check_user_change_status(self, pull_request, user, api=False):
163 163 reviewer = user.user_id in [x.user_id for x in
164 164 pull_request.reviewers]
165 165 return self.check_user_update(pull_request, user, api) or reviewer
166 166
167 167 def check_user_comment(self, pull_request, user):
168 168 owner = user.user_id == pull_request.user_id
169 169 return self.check_user_read(pull_request, user) or owner
170 170
171 171 def get(self, pull_request):
172 172 return self.__get_pull_request(pull_request)
173 173
174 174 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
175 175 opened_by=None, order_by=None,
176 176 order_dir='desc'):
177 177 repo = None
178 178 if repo_name:
179 179 repo = self._get_repo(repo_name)
180 180
181 181 q = PullRequest.query()
182 182
183 183 # source or target
184 184 if repo and source:
185 185 q = q.filter(PullRequest.source_repo == repo)
186 186 elif repo:
187 187 q = q.filter(PullRequest.target_repo == repo)
188 188
189 189 # closed,opened
190 190 if statuses:
191 191 q = q.filter(PullRequest.status.in_(statuses))
192 192
193 193 # opened by filter
194 194 if opened_by:
195 195 q = q.filter(PullRequest.user_id.in_(opened_by))
196 196
197 197 if order_by:
198 198 order_map = {
199 199 'name_raw': PullRequest.pull_request_id,
200 200 'title': PullRequest.title,
201 201 'updated_on_raw': PullRequest.updated_on,
202 202 'target_repo': PullRequest.target_repo_id
203 203 }
204 204 if order_dir == 'asc':
205 205 q = q.order_by(order_map[order_by].asc())
206 206 else:
207 207 q = q.order_by(order_map[order_by].desc())
208 208
209 209 return q
210 210
211 211 def count_all(self, repo_name, source=False, statuses=None,
212 212 opened_by=None):
213 213 """
214 214 Count the number of pull requests for a specific repository.
215 215
216 216 :param repo_name: target or source repo
217 217 :param source: boolean flag to specify if repo_name refers to source
218 218 :param statuses: list of pull request statuses
219 219 :param opened_by: author user of the pull request
220 220 :returns: int number of pull requests
221 221 """
222 222 q = self._prepare_get_all_query(
223 223 repo_name, source=source, statuses=statuses, opened_by=opened_by)
224 224
225 225 return q.count()
226 226
227 227 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
228 228 offset=0, length=None, order_by=None, order_dir='desc'):
229 229 """
230 230 Get all pull requests for a specific repository.
231 231
232 232 :param repo_name: target or source repo
233 233 :param source: boolean flag to specify if repo_name refers to source
234 234 :param statuses: list of pull request statuses
235 235 :param opened_by: author user of the pull request
236 236 :param offset: pagination offset
237 237 :param length: length of returned list
238 238 :param order_by: order of the returned list
239 239 :param order_dir: 'asc' or 'desc' ordering direction
240 240 :returns: list of pull requests
241 241 """
242 242 q = self._prepare_get_all_query(
243 243 repo_name, source=source, statuses=statuses, opened_by=opened_by,
244 244 order_by=order_by, order_dir=order_dir)
245 245
246 246 if length:
247 247 pull_requests = q.limit(length).offset(offset).all()
248 248 else:
249 249 pull_requests = q.all()
250 250
251 251 return pull_requests
252 252
253 253 def count_awaiting_review(self, repo_name, source=False, statuses=None,
254 254 opened_by=None):
255 255 """
256 256 Count the number of pull requests for a specific repository that are
257 257 awaiting review.
258 258
259 259 :param repo_name: target or source repo
260 260 :param source: boolean flag to specify if repo_name refers to source
261 261 :param statuses: list of pull request statuses
262 262 :param opened_by: author user of the pull request
263 263 :returns: int number of pull requests
264 264 """
265 265 pull_requests = self.get_awaiting_review(
266 266 repo_name, source=source, statuses=statuses, opened_by=opened_by)
267 267
268 268 return len(pull_requests)
269 269
270 270 def get_awaiting_review(self, repo_name, source=False, statuses=None,
271 271 opened_by=None, offset=0, length=None,
272 272 order_by=None, order_dir='desc'):
273 273 """
274 274 Get all pull requests for a specific repository that are awaiting
275 275 review.
276 276
277 277 :param repo_name: target or source repo
278 278 :param source: boolean flag to specify if repo_name refers to source
279 279 :param statuses: list of pull request statuses
280 280 :param opened_by: author user of the pull request
281 281 :param offset: pagination offset
282 282 :param length: length of returned list
283 283 :param order_by: order of the returned list
284 284 :param order_dir: 'asc' or 'desc' ordering direction
285 285 :returns: list of pull requests
286 286 """
287 287 pull_requests = self.get_all(
288 288 repo_name, source=source, statuses=statuses, opened_by=opened_by,
289 289 order_by=order_by, order_dir=order_dir)
290 290
291 291 _filtered_pull_requests = []
292 292 for pr in pull_requests:
293 293 status = pr.calculated_review_status()
294 294 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
295 295 ChangesetStatus.STATUS_UNDER_REVIEW]:
296 296 _filtered_pull_requests.append(pr)
297 297 if length:
298 298 return _filtered_pull_requests[offset:offset+length]
299 299 else:
300 300 return _filtered_pull_requests
301 301
302 302 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
303 303 opened_by=None, user_id=None):
304 304 """
305 305 Count the number of pull requests for a specific repository that are
306 306 awaiting review from a specific user.
307 307
308 308 :param repo_name: target or source repo
309 309 :param source: boolean flag to specify if repo_name refers to source
310 310 :param statuses: list of pull request statuses
311 311 :param opened_by: author user of the pull request
312 312 :param user_id: reviewer user of the pull request
313 313 :returns: int number of pull requests
314 314 """
315 315 pull_requests = self.get_awaiting_my_review(
316 316 repo_name, source=source, statuses=statuses, opened_by=opened_by,
317 317 user_id=user_id)
318 318
319 319 return len(pull_requests)
320 320
321 321 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
322 322 opened_by=None, user_id=None, offset=0,
323 323 length=None, order_by=None, order_dir='desc'):
324 324 """
325 325 Get all pull requests for a specific repository that are awaiting
326 326 review from a specific user.
327 327
328 328 :param repo_name: target or source repo
329 329 :param source: boolean flag to specify if repo_name refers to source
330 330 :param statuses: list of pull request statuses
331 331 :param opened_by: author user of the pull request
332 332 :param user_id: reviewer user of the pull request
333 333 :param offset: pagination offset
334 334 :param length: length of returned list
335 335 :param order_by: order of the returned list
336 336 :param order_dir: 'asc' or 'desc' ordering direction
337 337 :returns: list of pull requests
338 338 """
339 339 pull_requests = self.get_all(
340 340 repo_name, source=source, statuses=statuses, opened_by=opened_by,
341 341 order_by=order_by, order_dir=order_dir)
342 342
343 343 _my = PullRequestModel().get_not_reviewed(user_id)
344 344 my_participation = []
345 345 for pr in pull_requests:
346 346 if pr in _my:
347 347 my_participation.append(pr)
348 348 _filtered_pull_requests = my_participation
349 349 if length:
350 350 return _filtered_pull_requests[offset:offset+length]
351 351 else:
352 352 return _filtered_pull_requests
353 353
354 354 def get_not_reviewed(self, user_id):
355 355 return [
356 356 x.pull_request for x in PullRequestReviewers.query().filter(
357 357 PullRequestReviewers.user_id == user_id).all()
358 358 ]
359 359
360 360 def _prepare_participating_query(self, user_id=None, statuses=None,
361 361 order_by=None, order_dir='desc'):
362 362 q = PullRequest.query()
363 363 if user_id:
364 364 reviewers_subquery = Session().query(
365 365 PullRequestReviewers.pull_request_id).filter(
366 366 PullRequestReviewers.user_id == user_id).subquery()
367 367 user_filter = or_(
368 368 PullRequest.user_id == user_id,
369 369 PullRequest.pull_request_id.in_(reviewers_subquery)
370 370 )
371 371 q = PullRequest.query().filter(user_filter)
372 372
373 373 # closed,opened
374 374 if statuses:
375 375 q = q.filter(PullRequest.status.in_(statuses))
376 376
377 377 if order_by:
378 378 order_map = {
379 379 'name_raw': PullRequest.pull_request_id,
380 380 'title': PullRequest.title,
381 381 'updated_on_raw': PullRequest.updated_on,
382 382 'target_repo': PullRequest.target_repo_id
383 383 }
384 384 if order_dir == 'asc':
385 385 q = q.order_by(order_map[order_by].asc())
386 386 else:
387 387 q = q.order_by(order_map[order_by].desc())
388 388
389 389 return q
390 390
391 391 def count_im_participating_in(self, user_id=None, statuses=None):
392 392 q = self._prepare_participating_query(user_id, statuses=statuses)
393 393 return q.count()
394 394
395 395 def get_im_participating_in(
396 396 self, user_id=None, statuses=None, offset=0,
397 397 length=None, order_by=None, order_dir='desc'):
398 398 """
399 399 Get all Pull requests that i'm participating in, or i have opened
400 400 """
401 401
402 402 q = self._prepare_participating_query(
403 403 user_id, statuses=statuses, order_by=order_by,
404 404 order_dir=order_dir)
405 405
406 406 if length:
407 407 pull_requests = q.limit(length).offset(offset).all()
408 408 else:
409 409 pull_requests = q.all()
410 410
411 411 return pull_requests
412 412
413 413 def get_versions(self, pull_request):
414 414 """
415 415 returns version of pull request sorted by ID descending
416 416 """
417 417 return PullRequestVersion.query()\
418 418 .filter(PullRequestVersion.pull_request == pull_request)\
419 419 .order_by(PullRequestVersion.pull_request_version_id.asc())\
420 420 .all()
421 421
422 422 def get_pr_version(self, pull_request_id, version=None):
423 423 at_version = None
424 424
425 425 if version and version == 'latest':
426 426 pull_request_ver = PullRequest.get(pull_request_id)
427 427 pull_request_obj = pull_request_ver
428 428 _org_pull_request_obj = pull_request_obj
429 429 at_version = 'latest'
430 430 elif version:
431 431 pull_request_ver = PullRequestVersion.get_or_404(version)
432 432 pull_request_obj = pull_request_ver
433 433 _org_pull_request_obj = pull_request_ver.pull_request
434 434 at_version = pull_request_ver.pull_request_version_id
435 435 else:
436 436 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
437 437 pull_request_id)
438 438
439 439 pull_request_display_obj = PullRequest.get_pr_display_object(
440 440 pull_request_obj, _org_pull_request_obj)
441 441
442 442 return _org_pull_request_obj, pull_request_obj, \
443 443 pull_request_display_obj, at_version
444 444
445 445 def create(self, created_by, source_repo, source_ref, target_repo,
446 446 target_ref, revisions, reviewers, title, description=None,
447 447 description_renderer=None,
448 448 reviewer_data=None, translator=None, auth_user=None):
449 449 translator = translator or get_current_request().translate
450 450
451 451 created_by_user = self._get_user(created_by)
452 auth_user = auth_user or created_by_user
452 auth_user = auth_user or created_by_user.AuthUser()
453 453 source_repo = self._get_repo(source_repo)
454 454 target_repo = self._get_repo(target_repo)
455 455
456 456 pull_request = PullRequest()
457 457 pull_request.source_repo = source_repo
458 458 pull_request.source_ref = source_ref
459 459 pull_request.target_repo = target_repo
460 460 pull_request.target_ref = target_ref
461 461 pull_request.revisions = revisions
462 462 pull_request.title = title
463 463 pull_request.description = description
464 464 pull_request.description_renderer = description_renderer
465 465 pull_request.author = created_by_user
466 466 pull_request.reviewer_data = reviewer_data
467 467
468 468 Session().add(pull_request)
469 469 Session().flush()
470 470
471 471 reviewer_ids = set()
472 472 # members / reviewers
473 473 for reviewer_object in reviewers:
474 474 user_id, reasons, mandatory, rules = reviewer_object
475 475 user = self._get_user(user_id)
476 476
477 477 # skip duplicates
478 478 if user.user_id in reviewer_ids:
479 479 continue
480 480
481 481 reviewer_ids.add(user.user_id)
482 482
483 483 reviewer = PullRequestReviewers()
484 484 reviewer.user = user
485 485 reviewer.pull_request = pull_request
486 486 reviewer.reasons = reasons
487 487 reviewer.mandatory = mandatory
488 488
489 489 # NOTE(marcink): pick only first rule for now
490 490 rule_id = list(rules)[0] if rules else None
491 491 rule = RepoReviewRule.get(rule_id) if rule_id else None
492 492 if rule:
493 493 review_group = rule.user_group_vote_rule(user_id)
494 494 # we check if this particular reviewer is member of a voting group
495 495 if review_group:
496 496 # NOTE(marcink):
497 497 # can be that user is member of more but we pick the first same,
498 498 # same as default reviewers algo
499 499 review_group = review_group[0]
500 500
501 501 rule_data = {
502 502 'rule_name':
503 503 rule.review_rule_name,
504 504 'rule_user_group_entry_id':
505 505 review_group.repo_review_rule_users_group_id,
506 506 'rule_user_group_name':
507 507 review_group.users_group.users_group_name,
508 508 'rule_user_group_members':
509 509 [x.user.username for x in review_group.users_group.members],
510 510 'rule_user_group_members_id':
511 511 [x.user.user_id for x in review_group.users_group.members],
512 512 }
513 513 # e.g {'vote_rule': -1, 'mandatory': True}
514 514 rule_data.update(review_group.rule_data())
515 515
516 516 reviewer.rule_data = rule_data
517 517
518 518 Session().add(reviewer)
519 519 Session().flush()
520 520
521 521 # Set approval status to "Under Review" for all commits which are
522 522 # part of this pull request.
523 523 ChangesetStatusModel().set_status(
524 524 repo=target_repo,
525 525 status=ChangesetStatus.STATUS_UNDER_REVIEW,
526 526 user=created_by_user,
527 527 pull_request=pull_request
528 528 )
529 529 # we commit early at this point. This has to do with a fact
530 530 # that before queries do some row-locking. And because of that
531 531 # we need to commit and finish transation before below validate call
532 532 # that for large repos could be long resulting in long row locks
533 533 Session().commit()
534 534
535 535 # prepare workspace, and run initial merge simulation
536 536 MergeCheck.validate(
537 pull_request, user=created_by_user, translator=translator)
537 pull_request, auth_user=auth_user, translator=translator)
538 538
539 539 self.notify_reviewers(pull_request, reviewer_ids)
540 540 self._trigger_pull_request_hook(
541 541 pull_request, created_by_user, 'create')
542 542
543 543 creation_data = pull_request.get_api_data(with_merge_state=False)
544 544 self._log_audit_action(
545 545 'repo.pull_request.create', {'data': creation_data},
546 546 auth_user, pull_request)
547 547
548 548 return pull_request
549 549
550 550 def _trigger_pull_request_hook(self, pull_request, user, action):
551 551 pull_request = self.__get_pull_request(pull_request)
552 552 target_scm = pull_request.target_repo.scm_instance()
553 553 if action == 'create':
554 554 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
555 555 elif action == 'merge':
556 556 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
557 557 elif action == 'close':
558 558 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
559 559 elif action == 'review_status_change':
560 560 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
561 561 elif action == 'update':
562 562 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
563 563 else:
564 564 return
565 565
566 566 trigger_hook(
567 567 username=user.username,
568 568 repo_name=pull_request.target_repo.repo_name,
569 569 repo_alias=target_scm.alias,
570 570 pull_request=pull_request)
571 571
572 572 def _get_commit_ids(self, pull_request):
573 573 """
574 574 Return the commit ids of the merged pull request.
575 575
576 576 This method is not dealing correctly yet with the lack of autoupdates
577 577 nor with the implicit target updates.
578 578 For example: if a commit in the source repo is already in the target it
579 579 will be reported anyways.
580 580 """
581 581 merge_rev = pull_request.merge_rev
582 582 if merge_rev is None:
583 583 raise ValueError('This pull request was not merged yet')
584 584
585 585 commit_ids = list(pull_request.revisions)
586 586 if merge_rev not in commit_ids:
587 587 commit_ids.append(merge_rev)
588 588
589 589 return commit_ids
590 590
591 591 def merge_repo(self, pull_request, user, extras):
592 592 log.debug("Merging pull request %s", pull_request.pull_request_id)
593 593 merge_state = self._merge_pull_request(pull_request, user, extras)
594 594 if merge_state.executed:
595 595 log.debug(
596 596 "Merge was successful, updating the pull request comments.")
597 597 self._comment_and_close_pr(pull_request, user, merge_state)
598 598
599 599 self._log_audit_action(
600 600 'repo.pull_request.merge',
601 601 {'merge_state': merge_state.__dict__},
602 602 user, pull_request)
603 603
604 604 else:
605 605 log.warn("Merge failed, not updating the pull request.")
606 606 return merge_state
607 607
608 608 def _merge_pull_request(self, pull_request, user, extras, merge_msg=None):
609 609 target_vcs = pull_request.target_repo.scm_instance()
610 610 source_vcs = pull_request.source_repo.scm_instance()
611 611 target_ref = self._refresh_reference(
612 612 pull_request.target_ref_parts, target_vcs)
613 613
614 614 message = merge_msg or (
615 615 'Merge pull request #%(pr_id)s from '
616 616 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
617 617 'pr_id': pull_request.pull_request_id,
618 618 'source_repo': source_vcs.name,
619 619 'source_ref_name': pull_request.source_ref_parts.name,
620 620 'pr_title': pull_request.title
621 621 }
622 622
623 623 workspace_id = self._workspace_id(pull_request)
624 624 repo_id = pull_request.target_repo.repo_id
625 625 use_rebase = self._use_rebase_for_merging(pull_request)
626 626 close_branch = self._close_branch_before_merging(pull_request)
627 627
628 628 callback_daemon, extras = prepare_callback_daemon(
629 629 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
630 630 host=vcs_settings.HOOKS_HOST,
631 631 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
632 632
633 633 with callback_daemon:
634 634 # TODO: johbo: Implement a clean way to run a config_override
635 635 # for a single call.
636 636 target_vcs.config.set(
637 637 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
638 638 merge_state = target_vcs.merge(
639 639 repo_id, workspace_id, target_ref, source_vcs,
640 640 pull_request.source_ref_parts,
641 641 user_name=user.username, user_email=user.email,
642 642 message=message, use_rebase=use_rebase,
643 643 close_branch=close_branch)
644 644 return merge_state
645 645
646 646 def _comment_and_close_pr(self, pull_request, user, merge_state, close_msg=None):
647 647 pull_request.merge_rev = merge_state.merge_ref.commit_id
648 648 pull_request.updated_on = datetime.datetime.now()
649 649 close_msg = close_msg or 'Pull request merged and closed'
650 650
651 651 CommentsModel().create(
652 652 text=safe_unicode(close_msg),
653 653 repo=pull_request.target_repo.repo_id,
654 654 user=user.user_id,
655 655 pull_request=pull_request.pull_request_id,
656 656 f_path=None,
657 657 line_no=None,
658 658 closing_pr=True
659 659 )
660 660
661 661 Session().add(pull_request)
662 662 Session().flush()
663 663 # TODO: paris: replace invalidation with less radical solution
664 664 ScmModel().mark_for_invalidation(
665 665 pull_request.target_repo.repo_name)
666 666 self._trigger_pull_request_hook(pull_request, user, 'merge')
667 667
668 668 def has_valid_update_type(self, pull_request):
669 669 source_ref_type = pull_request.source_ref_parts.type
670 670 return source_ref_type in ['book', 'branch', 'tag']
671 671
672 672 def update_commits(self, pull_request):
673 673 """
674 674 Get the updated list of commits for the pull request
675 675 and return the new pull request version and the list
676 676 of commits processed by this update action
677 677 """
678 678 pull_request = self.__get_pull_request(pull_request)
679 679 source_ref_type = pull_request.source_ref_parts.type
680 680 source_ref_name = pull_request.source_ref_parts.name
681 681 source_ref_id = pull_request.source_ref_parts.commit_id
682 682
683 683 target_ref_type = pull_request.target_ref_parts.type
684 684 target_ref_name = pull_request.target_ref_parts.name
685 685 target_ref_id = pull_request.target_ref_parts.commit_id
686 686
687 687 if not self.has_valid_update_type(pull_request):
688 688 log.debug(
689 689 "Skipping update of pull request %s due to ref type: %s",
690 690 pull_request, source_ref_type)
691 691 return UpdateResponse(
692 692 executed=False,
693 693 reason=UpdateFailureReason.WRONG_REF_TYPE,
694 694 old=pull_request, new=None, changes=None,
695 695 source_changed=False, target_changed=False)
696 696
697 697 # source repo
698 698 source_repo = pull_request.source_repo.scm_instance()
699 699 try:
700 700 source_commit = source_repo.get_commit(commit_id=source_ref_name)
701 701 except CommitDoesNotExistError:
702 702 return UpdateResponse(
703 703 executed=False,
704 704 reason=UpdateFailureReason.MISSING_SOURCE_REF,
705 705 old=pull_request, new=None, changes=None,
706 706 source_changed=False, target_changed=False)
707 707
708 708 source_changed = source_ref_id != source_commit.raw_id
709 709
710 710 # target repo
711 711 target_repo = pull_request.target_repo.scm_instance()
712 712 try:
713 713 target_commit = target_repo.get_commit(commit_id=target_ref_name)
714 714 except CommitDoesNotExistError:
715 715 return UpdateResponse(
716 716 executed=False,
717 717 reason=UpdateFailureReason.MISSING_TARGET_REF,
718 718 old=pull_request, new=None, changes=None,
719 719 source_changed=False, target_changed=False)
720 720 target_changed = target_ref_id != target_commit.raw_id
721 721
722 722 if not (source_changed or target_changed):
723 723 log.debug("Nothing changed in pull request %s", pull_request)
724 724 return UpdateResponse(
725 725 executed=False,
726 726 reason=UpdateFailureReason.NO_CHANGE,
727 727 old=pull_request, new=None, changes=None,
728 728 source_changed=target_changed, target_changed=source_changed)
729 729
730 730 change_in_found = 'target repo' if target_changed else 'source repo'
731 731 log.debug('Updating pull request because of change in %s detected',
732 732 change_in_found)
733 733
734 734 # Finally there is a need for an update, in case of source change
735 735 # we create a new version, else just an update
736 736 if source_changed:
737 737 pull_request_version = self._create_version_from_snapshot(pull_request)
738 738 self._link_comments_to_version(pull_request_version)
739 739 else:
740 740 try:
741 741 ver = pull_request.versions[-1]
742 742 except IndexError:
743 743 ver = None
744 744
745 745 pull_request.pull_request_version_id = \
746 746 ver.pull_request_version_id if ver else None
747 747 pull_request_version = pull_request
748 748
749 749 try:
750 750 if target_ref_type in ('tag', 'branch', 'book'):
751 751 target_commit = target_repo.get_commit(target_ref_name)
752 752 else:
753 753 target_commit = target_repo.get_commit(target_ref_id)
754 754 except CommitDoesNotExistError:
755 755 return UpdateResponse(
756 756 executed=False,
757 757 reason=UpdateFailureReason.MISSING_TARGET_REF,
758 758 old=pull_request, new=None, changes=None,
759 759 source_changed=source_changed, target_changed=target_changed)
760 760
761 761 # re-compute commit ids
762 762 old_commit_ids = pull_request.revisions
763 763 pre_load = ["author", "branch", "date", "message"]
764 764 commit_ranges = target_repo.compare(
765 765 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
766 766 pre_load=pre_load)
767 767
768 768 ancestor = target_repo.get_common_ancestor(
769 769 target_commit.raw_id, source_commit.raw_id, source_repo)
770 770
771 771 pull_request.source_ref = '%s:%s:%s' % (
772 772 source_ref_type, source_ref_name, source_commit.raw_id)
773 773 pull_request.target_ref = '%s:%s:%s' % (
774 774 target_ref_type, target_ref_name, ancestor)
775 775
776 776 pull_request.revisions = [
777 777 commit.raw_id for commit in reversed(commit_ranges)]
778 778 pull_request.updated_on = datetime.datetime.now()
779 779 Session().add(pull_request)
780 780 new_commit_ids = pull_request.revisions
781 781
782 782 old_diff_data, new_diff_data = self._generate_update_diffs(
783 783 pull_request, pull_request_version)
784 784
785 785 # calculate commit and file changes
786 786 changes = self._calculate_commit_id_changes(
787 787 old_commit_ids, new_commit_ids)
788 788 file_changes = self._calculate_file_changes(
789 789 old_diff_data, new_diff_data)
790 790
791 791 # set comments as outdated if DIFFS changed
792 792 CommentsModel().outdate_comments(
793 793 pull_request, old_diff_data=old_diff_data,
794 794 new_diff_data=new_diff_data)
795 795
796 796 commit_changes = (changes.added or changes.removed)
797 797 file_node_changes = (
798 798 file_changes.added or file_changes.modified or file_changes.removed)
799 799 pr_has_changes = commit_changes or file_node_changes
800 800
801 801 # Add an automatic comment to the pull request, in case
802 802 # anything has changed
803 803 if pr_has_changes:
804 804 update_comment = CommentsModel().create(
805 805 text=self._render_update_message(changes, file_changes),
806 806 repo=pull_request.target_repo,
807 807 user=pull_request.author,
808 808 pull_request=pull_request,
809 809 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
810 810
811 811 # Update status to "Under Review" for added commits
812 812 for commit_id in changes.added:
813 813 ChangesetStatusModel().set_status(
814 814 repo=pull_request.source_repo,
815 815 status=ChangesetStatus.STATUS_UNDER_REVIEW,
816 816 comment=update_comment,
817 817 user=pull_request.author,
818 818 pull_request=pull_request,
819 819 revision=commit_id)
820 820
821 821 log.debug(
822 822 'Updated pull request %s, added_ids: %s, common_ids: %s, '
823 823 'removed_ids: %s', pull_request.pull_request_id,
824 824 changes.added, changes.common, changes.removed)
825 825 log.debug(
826 826 'Updated pull request with the following file changes: %s',
827 827 file_changes)
828 828
829 829 log.info(
830 830 "Updated pull request %s from commit %s to commit %s, "
831 831 "stored new version %s of this pull request.",
832 832 pull_request.pull_request_id, source_ref_id,
833 833 pull_request.source_ref_parts.commit_id,
834 834 pull_request_version.pull_request_version_id)
835 835 Session().commit()
836 836 self._trigger_pull_request_hook(
837 837 pull_request, pull_request.author, 'update')
838 838
839 839 return UpdateResponse(
840 840 executed=True, reason=UpdateFailureReason.NONE,
841 841 old=pull_request, new=pull_request_version, changes=changes,
842 842 source_changed=source_changed, target_changed=target_changed)
843 843
844 844 def _create_version_from_snapshot(self, pull_request):
845 845 version = PullRequestVersion()
846 846 version.title = pull_request.title
847 847 version.description = pull_request.description
848 848 version.status = pull_request.status
849 849 version.created_on = datetime.datetime.now()
850 850 version.updated_on = pull_request.updated_on
851 851 version.user_id = pull_request.user_id
852 852 version.source_repo = pull_request.source_repo
853 853 version.source_ref = pull_request.source_ref
854 854 version.target_repo = pull_request.target_repo
855 855 version.target_ref = pull_request.target_ref
856 856
857 857 version._last_merge_source_rev = pull_request._last_merge_source_rev
858 858 version._last_merge_target_rev = pull_request._last_merge_target_rev
859 859 version.last_merge_status = pull_request.last_merge_status
860 860 version.shadow_merge_ref = pull_request.shadow_merge_ref
861 861 version.merge_rev = pull_request.merge_rev
862 862 version.reviewer_data = pull_request.reviewer_data
863 863
864 864 version.revisions = pull_request.revisions
865 865 version.pull_request = pull_request
866 866 Session().add(version)
867 867 Session().flush()
868 868
869 869 return version
870 870
871 871 def _generate_update_diffs(self, pull_request, pull_request_version):
872 872
873 873 diff_context = (
874 874 self.DIFF_CONTEXT +
875 875 CommentsModel.needed_extra_diff_context())
876 876
877 877 source_repo = pull_request_version.source_repo
878 878 source_ref_id = pull_request_version.source_ref_parts.commit_id
879 879 target_ref_id = pull_request_version.target_ref_parts.commit_id
880 880 old_diff = self._get_diff_from_pr_or_version(
881 881 source_repo, source_ref_id, target_ref_id, context=diff_context)
882 882
883 883 source_repo = pull_request.source_repo
884 884 source_ref_id = pull_request.source_ref_parts.commit_id
885 885 target_ref_id = pull_request.target_ref_parts.commit_id
886 886
887 887 new_diff = self._get_diff_from_pr_or_version(
888 888 source_repo, source_ref_id, target_ref_id, context=diff_context)
889 889
890 890 old_diff_data = diffs.DiffProcessor(old_diff)
891 891 old_diff_data.prepare()
892 892 new_diff_data = diffs.DiffProcessor(new_diff)
893 893 new_diff_data.prepare()
894 894
895 895 return old_diff_data, new_diff_data
896 896
897 897 def _link_comments_to_version(self, pull_request_version):
898 898 """
899 899 Link all unlinked comments of this pull request to the given version.
900 900
901 901 :param pull_request_version: The `PullRequestVersion` to which
902 902 the comments shall be linked.
903 903
904 904 """
905 905 pull_request = pull_request_version.pull_request
906 906 comments = ChangesetComment.query()\
907 907 .filter(
908 908 # TODO: johbo: Should we query for the repo at all here?
909 909 # Pending decision on how comments of PRs are to be related
910 910 # to either the source repo, the target repo or no repo at all.
911 911 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
912 912 ChangesetComment.pull_request == pull_request,
913 913 ChangesetComment.pull_request_version == None)\
914 914 .order_by(ChangesetComment.comment_id.asc())
915 915
916 916 # TODO: johbo: Find out why this breaks if it is done in a bulk
917 917 # operation.
918 918 for comment in comments:
919 919 comment.pull_request_version_id = (
920 920 pull_request_version.pull_request_version_id)
921 921 Session().add(comment)
922 922
923 923 def _calculate_commit_id_changes(self, old_ids, new_ids):
924 924 added = [x for x in new_ids if x not in old_ids]
925 925 common = [x for x in new_ids if x in old_ids]
926 926 removed = [x for x in old_ids if x not in new_ids]
927 927 total = new_ids
928 928 return ChangeTuple(added, common, removed, total)
929 929
930 930 def _calculate_file_changes(self, old_diff_data, new_diff_data):
931 931
932 932 old_files = OrderedDict()
933 933 for diff_data in old_diff_data.parsed_diff:
934 934 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
935 935
936 936 added_files = []
937 937 modified_files = []
938 938 removed_files = []
939 939 for diff_data in new_diff_data.parsed_diff:
940 940 new_filename = diff_data['filename']
941 941 new_hash = md5_safe(diff_data['raw_diff'])
942 942
943 943 old_hash = old_files.get(new_filename)
944 944 if not old_hash:
945 945 # file is not present in old diff, means it's added
946 946 added_files.append(new_filename)
947 947 else:
948 948 if new_hash != old_hash:
949 949 modified_files.append(new_filename)
950 950 # now remove a file from old, since we have seen it already
951 951 del old_files[new_filename]
952 952
953 953 # removed files is when there are present in old, but not in NEW,
954 954 # since we remove old files that are present in new diff, left-overs
955 955 # if any should be the removed files
956 956 removed_files.extend(old_files.keys())
957 957
958 958 return FileChangeTuple(added_files, modified_files, removed_files)
959 959
960 960 def _render_update_message(self, changes, file_changes):
961 961 """
962 962 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
963 963 so it's always looking the same disregarding on which default
964 964 renderer system is using.
965 965
966 966 :param changes: changes named tuple
967 967 :param file_changes: file changes named tuple
968 968
969 969 """
970 970 new_status = ChangesetStatus.get_status_lbl(
971 971 ChangesetStatus.STATUS_UNDER_REVIEW)
972 972
973 973 changed_files = (
974 974 file_changes.added + file_changes.modified + file_changes.removed)
975 975
976 976 params = {
977 977 'under_review_label': new_status,
978 978 'added_commits': changes.added,
979 979 'removed_commits': changes.removed,
980 980 'changed_files': changed_files,
981 981 'added_files': file_changes.added,
982 982 'modified_files': file_changes.modified,
983 983 'removed_files': file_changes.removed,
984 984 }
985 985 renderer = RstTemplateRenderer()
986 986 return renderer.render('pull_request_update.mako', **params)
987 987
988 988 def edit(self, pull_request, title, description, description_renderer, user):
989 989 pull_request = self.__get_pull_request(pull_request)
990 990 old_data = pull_request.get_api_data(with_merge_state=False)
991 991 if pull_request.is_closed():
992 992 raise ValueError('This pull request is closed')
993 993 if title:
994 994 pull_request.title = title
995 995 pull_request.description = description
996 996 pull_request.updated_on = datetime.datetime.now()
997 997 pull_request.description_renderer = description_renderer
998 998 Session().add(pull_request)
999 999 self._log_audit_action(
1000 1000 'repo.pull_request.edit', {'old_data': old_data},
1001 1001 user, pull_request)
1002 1002
1003 1003 def update_reviewers(self, pull_request, reviewer_data, user):
1004 1004 """
1005 1005 Update the reviewers in the pull request
1006 1006
1007 1007 :param pull_request: the pr to update
1008 1008 :param reviewer_data: list of tuples
1009 1009 [(user, ['reason1', 'reason2'], mandatory_flag, [rules])]
1010 1010 """
1011 1011 pull_request = self.__get_pull_request(pull_request)
1012 1012 if pull_request.is_closed():
1013 1013 raise ValueError('This pull request is closed')
1014 1014
1015 1015 reviewers = {}
1016 1016 for user_id, reasons, mandatory, rules in reviewer_data:
1017 1017 if isinstance(user_id, (int, basestring)):
1018 1018 user_id = self._get_user(user_id).user_id
1019 1019 reviewers[user_id] = {
1020 1020 'reasons': reasons, 'mandatory': mandatory}
1021 1021
1022 1022 reviewers_ids = set(reviewers.keys())
1023 1023 current_reviewers = PullRequestReviewers.query()\
1024 1024 .filter(PullRequestReviewers.pull_request ==
1025 1025 pull_request).all()
1026 1026 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
1027 1027
1028 1028 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
1029 1029 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
1030 1030
1031 1031 log.debug("Adding %s reviewers", ids_to_add)
1032 1032 log.debug("Removing %s reviewers", ids_to_remove)
1033 1033 changed = False
1034 1034 for uid in ids_to_add:
1035 1035 changed = True
1036 1036 _usr = self._get_user(uid)
1037 1037 reviewer = PullRequestReviewers()
1038 1038 reviewer.user = _usr
1039 1039 reviewer.pull_request = pull_request
1040 1040 reviewer.reasons = reviewers[uid]['reasons']
1041 1041 # NOTE(marcink): mandatory shouldn't be changed now
1042 1042 # reviewer.mandatory = reviewers[uid]['reasons']
1043 1043 Session().add(reviewer)
1044 1044 self._log_audit_action(
1045 1045 'repo.pull_request.reviewer.add', {'data': reviewer.get_dict()},
1046 1046 user, pull_request)
1047 1047
1048 1048 for uid in ids_to_remove:
1049 1049 changed = True
1050 1050 reviewers = PullRequestReviewers.query()\
1051 1051 .filter(PullRequestReviewers.user_id == uid,
1052 1052 PullRequestReviewers.pull_request == pull_request)\
1053 1053 .all()
1054 1054 # use .all() in case we accidentally added the same person twice
1055 1055 # this CAN happen due to the lack of DB checks
1056 1056 for obj in reviewers:
1057 1057 old_data = obj.get_dict()
1058 1058 Session().delete(obj)
1059 1059 self._log_audit_action(
1060 1060 'repo.pull_request.reviewer.delete',
1061 1061 {'old_data': old_data}, user, pull_request)
1062 1062
1063 1063 if changed:
1064 1064 pull_request.updated_on = datetime.datetime.now()
1065 1065 Session().add(pull_request)
1066 1066
1067 1067 self.notify_reviewers(pull_request, ids_to_add)
1068 1068 return ids_to_add, ids_to_remove
1069 1069
1070 1070 def get_url(self, pull_request, request=None, permalink=False):
1071 1071 if not request:
1072 1072 request = get_current_request()
1073 1073
1074 1074 if permalink:
1075 1075 return request.route_url(
1076 1076 'pull_requests_global',
1077 1077 pull_request_id=pull_request.pull_request_id,)
1078 1078 else:
1079 1079 return request.route_url('pullrequest_show',
1080 1080 repo_name=safe_str(pull_request.target_repo.repo_name),
1081 1081 pull_request_id=pull_request.pull_request_id,)
1082 1082
1083 1083 def get_shadow_clone_url(self, pull_request, request=None):
1084 1084 """
1085 1085 Returns qualified url pointing to the shadow repository. If this pull
1086 1086 request is closed there is no shadow repository and ``None`` will be
1087 1087 returned.
1088 1088 """
1089 1089 if pull_request.is_closed():
1090 1090 return None
1091 1091 else:
1092 1092 pr_url = urllib.unquote(self.get_url(pull_request, request=request))
1093 1093 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1094 1094
1095 1095 def notify_reviewers(self, pull_request, reviewers_ids):
1096 1096 # notification to reviewers
1097 1097 if not reviewers_ids:
1098 1098 return
1099 1099
1100 1100 pull_request_obj = pull_request
1101 1101 # get the current participants of this pull request
1102 1102 recipients = reviewers_ids
1103 1103 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1104 1104
1105 1105 pr_source_repo = pull_request_obj.source_repo
1106 1106 pr_target_repo = pull_request_obj.target_repo
1107 1107
1108 1108 pr_url = h.route_url('pullrequest_show',
1109 1109 repo_name=pr_target_repo.repo_name,
1110 1110 pull_request_id=pull_request_obj.pull_request_id,)
1111 1111
1112 1112 # set some variables for email notification
1113 1113 pr_target_repo_url = h.route_url(
1114 1114 'repo_summary', repo_name=pr_target_repo.repo_name)
1115 1115
1116 1116 pr_source_repo_url = h.route_url(
1117 1117 'repo_summary', repo_name=pr_source_repo.repo_name)
1118 1118
1119 1119 # pull request specifics
1120 1120 pull_request_commits = [
1121 1121 (x.raw_id, x.message)
1122 1122 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1123 1123
1124 1124 kwargs = {
1125 1125 'user': pull_request.author,
1126 1126 'pull_request': pull_request_obj,
1127 1127 'pull_request_commits': pull_request_commits,
1128 1128
1129 1129 'pull_request_target_repo': pr_target_repo,
1130 1130 'pull_request_target_repo_url': pr_target_repo_url,
1131 1131
1132 1132 'pull_request_source_repo': pr_source_repo,
1133 1133 'pull_request_source_repo_url': pr_source_repo_url,
1134 1134
1135 1135 'pull_request_url': pr_url,
1136 1136 }
1137 1137
1138 1138 # pre-generate the subject for notification itself
1139 1139 (subject,
1140 1140 _h, _e, # we don't care about those
1141 1141 body_plaintext) = EmailNotificationModel().render_email(
1142 1142 notification_type, **kwargs)
1143 1143
1144 1144 # create notification objects, and emails
1145 1145 NotificationModel().create(
1146 1146 created_by=pull_request.author,
1147 1147 notification_subject=subject,
1148 1148 notification_body=body_plaintext,
1149 1149 notification_type=notification_type,
1150 1150 recipients=recipients,
1151 1151 email_kwargs=kwargs,
1152 1152 )
1153 1153
1154 1154 def delete(self, pull_request, user):
1155 1155 pull_request = self.__get_pull_request(pull_request)
1156 1156 old_data = pull_request.get_api_data(with_merge_state=False)
1157 1157 self._cleanup_merge_workspace(pull_request)
1158 1158 self._log_audit_action(
1159 1159 'repo.pull_request.delete', {'old_data': old_data},
1160 1160 user, pull_request)
1161 1161 Session().delete(pull_request)
1162 1162
1163 1163 def close_pull_request(self, pull_request, user):
1164 1164 pull_request = self.__get_pull_request(pull_request)
1165 1165 self._cleanup_merge_workspace(pull_request)
1166 1166 pull_request.status = PullRequest.STATUS_CLOSED
1167 1167 pull_request.updated_on = datetime.datetime.now()
1168 1168 Session().add(pull_request)
1169 1169 self._trigger_pull_request_hook(
1170 1170 pull_request, pull_request.author, 'close')
1171 1171
1172 1172 pr_data = pull_request.get_api_data(with_merge_state=False)
1173 1173 self._log_audit_action(
1174 1174 'repo.pull_request.close', {'data': pr_data}, user, pull_request)
1175 1175
1176 1176 def close_pull_request_with_comment(
1177 1177 self, pull_request, user, repo, message=None):
1178 1178
1179 1179 pull_request_review_status = pull_request.calculated_review_status()
1180 1180
1181 1181 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1182 1182 # approved only if we have voting consent
1183 1183 status = ChangesetStatus.STATUS_APPROVED
1184 1184 else:
1185 1185 status = ChangesetStatus.STATUS_REJECTED
1186 1186 status_lbl = ChangesetStatus.get_status_lbl(status)
1187 1187
1188 1188 default_message = (
1189 1189 'Closing with status change {transition_icon} {status}.'
1190 1190 ).format(transition_icon='>', status=status_lbl)
1191 1191 text = message or default_message
1192 1192
1193 1193 # create a comment, and link it to new status
1194 1194 comment = CommentsModel().create(
1195 1195 text=text,
1196 1196 repo=repo.repo_id,
1197 1197 user=user.user_id,
1198 1198 pull_request=pull_request.pull_request_id,
1199 1199 status_change=status_lbl,
1200 1200 status_change_type=status,
1201 1201 closing_pr=True
1202 1202 )
1203 1203
1204 1204 # calculate old status before we change it
1205 1205 old_calculated_status = pull_request.calculated_review_status()
1206 1206 ChangesetStatusModel().set_status(
1207 1207 repo.repo_id,
1208 1208 status,
1209 1209 user.user_id,
1210 1210 comment=comment,
1211 1211 pull_request=pull_request.pull_request_id
1212 1212 )
1213 1213
1214 1214 Session().flush()
1215 1215 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1216 1216 # we now calculate the status of pull request again, and based on that
1217 1217 # calculation trigger status change. This might happen in cases
1218 1218 # that non-reviewer admin closes a pr, which means his vote doesn't
1219 1219 # change the status, while if he's a reviewer this might change it.
1220 1220 calculated_status = pull_request.calculated_review_status()
1221 1221 if old_calculated_status != calculated_status:
1222 1222 self._trigger_pull_request_hook(
1223 1223 pull_request, user, 'review_status_change')
1224 1224
1225 1225 # finally close the PR
1226 1226 PullRequestModel().close_pull_request(
1227 1227 pull_request.pull_request_id, user)
1228 1228
1229 1229 return comment, status
1230 1230
1231 1231 def merge_status(self, pull_request, translator=None,
1232 1232 force_shadow_repo_refresh=False):
1233 1233 _ = translator or get_current_request().translate
1234 1234
1235 1235 if not self._is_merge_enabled(pull_request):
1236 1236 return False, _('Server-side pull request merging is disabled.')
1237 1237 if pull_request.is_closed():
1238 1238 return False, _('This pull request is closed.')
1239 1239 merge_possible, msg = self._check_repo_requirements(
1240 1240 target=pull_request.target_repo, source=pull_request.source_repo,
1241 1241 translator=_)
1242 1242 if not merge_possible:
1243 1243 return merge_possible, msg
1244 1244
1245 1245 try:
1246 1246 resp = self._try_merge(
1247 1247 pull_request,
1248 1248 force_shadow_repo_refresh=force_shadow_repo_refresh)
1249 1249 log.debug("Merge response: %s", resp)
1250 1250 status = resp.possible, self.merge_status_message(
1251 1251 resp.failure_reason)
1252 1252 except NotImplementedError:
1253 1253 status = False, _('Pull request merging is not supported.')
1254 1254
1255 1255 return status
1256 1256
1257 1257 def _check_repo_requirements(self, target, source, translator):
1258 1258 """
1259 1259 Check if `target` and `source` have compatible requirements.
1260 1260
1261 1261 Currently this is just checking for largefiles.
1262 1262 """
1263 1263 _ = translator
1264 1264 target_has_largefiles = self._has_largefiles(target)
1265 1265 source_has_largefiles = self._has_largefiles(source)
1266 1266 merge_possible = True
1267 1267 message = u''
1268 1268
1269 1269 if target_has_largefiles != source_has_largefiles:
1270 1270 merge_possible = False
1271 1271 if source_has_largefiles:
1272 1272 message = _(
1273 1273 'Target repository large files support is disabled.')
1274 1274 else:
1275 1275 message = _(
1276 1276 'Source repository large files support is disabled.')
1277 1277
1278 1278 return merge_possible, message
1279 1279
1280 1280 def _has_largefiles(self, repo):
1281 1281 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1282 1282 'extensions', 'largefiles')
1283 1283 return largefiles_ui and largefiles_ui[0].active
1284 1284
1285 1285 def _try_merge(self, pull_request, force_shadow_repo_refresh=False):
1286 1286 """
1287 1287 Try to merge the pull request and return the merge status.
1288 1288 """
1289 1289 log.debug(
1290 1290 "Trying out if the pull request %s can be merged. Force_refresh=%s",
1291 1291 pull_request.pull_request_id, force_shadow_repo_refresh)
1292 1292 target_vcs = pull_request.target_repo.scm_instance()
1293 1293
1294 1294 # Refresh the target reference.
1295 1295 try:
1296 1296 target_ref = self._refresh_reference(
1297 1297 pull_request.target_ref_parts, target_vcs)
1298 1298 except CommitDoesNotExistError:
1299 1299 merge_state = MergeResponse(
1300 1300 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
1301 1301 return merge_state
1302 1302
1303 1303 target_locked = pull_request.target_repo.locked
1304 1304 if target_locked and target_locked[0]:
1305 1305 log.debug("The target repository is locked.")
1306 1306 merge_state = MergeResponse(
1307 1307 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
1308 1308 elif force_shadow_repo_refresh or self._needs_merge_state_refresh(
1309 1309 pull_request, target_ref):
1310 1310 log.debug("Refreshing the merge status of the repository.")
1311 1311 merge_state = self._refresh_merge_state(
1312 1312 pull_request, target_vcs, target_ref)
1313 1313 else:
1314 1314 possible = pull_request.\
1315 1315 last_merge_status == MergeFailureReason.NONE
1316 1316 merge_state = MergeResponse(
1317 1317 possible, False, None, pull_request.last_merge_status)
1318 1318
1319 1319 return merge_state
1320 1320
1321 1321 def _refresh_reference(self, reference, vcs_repository):
1322 1322 if reference.type in ('branch', 'book'):
1323 1323 name_or_id = reference.name
1324 1324 else:
1325 1325 name_or_id = reference.commit_id
1326 1326 refreshed_commit = vcs_repository.get_commit(name_or_id)
1327 1327 refreshed_reference = Reference(
1328 1328 reference.type, reference.name, refreshed_commit.raw_id)
1329 1329 return refreshed_reference
1330 1330
1331 1331 def _needs_merge_state_refresh(self, pull_request, target_reference):
1332 1332 return not(
1333 1333 pull_request.revisions and
1334 1334 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1335 1335 target_reference.commit_id == pull_request._last_merge_target_rev)
1336 1336
1337 1337 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1338 1338 workspace_id = self._workspace_id(pull_request)
1339 1339 source_vcs = pull_request.source_repo.scm_instance()
1340 1340 repo_id = pull_request.target_repo.repo_id
1341 1341 use_rebase = self._use_rebase_for_merging(pull_request)
1342 1342 close_branch = self._close_branch_before_merging(pull_request)
1343 1343 merge_state = target_vcs.merge(
1344 1344 repo_id, workspace_id,
1345 1345 target_reference, source_vcs, pull_request.source_ref_parts,
1346 1346 dry_run=True, use_rebase=use_rebase,
1347 1347 close_branch=close_branch)
1348 1348
1349 1349 # Do not store the response if there was an unknown error.
1350 1350 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1351 1351 pull_request._last_merge_source_rev = \
1352 1352 pull_request.source_ref_parts.commit_id
1353 1353 pull_request._last_merge_target_rev = target_reference.commit_id
1354 1354 pull_request.last_merge_status = merge_state.failure_reason
1355 1355 pull_request.shadow_merge_ref = merge_state.merge_ref
1356 1356 Session().add(pull_request)
1357 1357 Session().commit()
1358 1358
1359 1359 return merge_state
1360 1360
1361 1361 def _workspace_id(self, pull_request):
1362 1362 workspace_id = 'pr-%s' % pull_request.pull_request_id
1363 1363 return workspace_id
1364 1364
1365 1365 def merge_status_message(self, status_code):
1366 1366 """
1367 1367 Return a human friendly error message for the given merge status code.
1368 1368 """
1369 1369 return self.MERGE_STATUS_MESSAGES[status_code]
1370 1370
1371 1371 def generate_repo_data(self, repo, commit_id=None, branch=None,
1372 1372 bookmark=None, translator=None):
1373 1373 from rhodecode.model.repo import RepoModel
1374 1374
1375 1375 all_refs, selected_ref = \
1376 1376 self._get_repo_pullrequest_sources(
1377 1377 repo.scm_instance(), commit_id=commit_id,
1378 1378 branch=branch, bookmark=bookmark, translator=translator)
1379 1379
1380 1380 refs_select2 = []
1381 1381 for element in all_refs:
1382 1382 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1383 1383 refs_select2.append({'text': element[1], 'children': children})
1384 1384
1385 1385 return {
1386 1386 'user': {
1387 1387 'user_id': repo.user.user_id,
1388 1388 'username': repo.user.username,
1389 1389 'firstname': repo.user.first_name,
1390 1390 'lastname': repo.user.last_name,
1391 1391 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1392 1392 },
1393 1393 'name': repo.repo_name,
1394 1394 'link': RepoModel().get_url(repo),
1395 1395 'description': h.chop_at_smart(repo.description_safe, '\n'),
1396 1396 'refs': {
1397 1397 'all_refs': all_refs,
1398 1398 'selected_ref': selected_ref,
1399 1399 'select2_refs': refs_select2
1400 1400 }
1401 1401 }
1402 1402
1403 1403 def generate_pullrequest_title(self, source, source_ref, target):
1404 1404 return u'{source}#{at_ref} to {target}'.format(
1405 1405 source=source,
1406 1406 at_ref=source_ref,
1407 1407 target=target,
1408 1408 )
1409 1409
1410 1410 def _cleanup_merge_workspace(self, pull_request):
1411 1411 # Merging related cleanup
1412 1412 repo_id = pull_request.target_repo.repo_id
1413 1413 target_scm = pull_request.target_repo.scm_instance()
1414 1414 workspace_id = self._workspace_id(pull_request)
1415 1415
1416 1416 try:
1417 1417 target_scm.cleanup_merge_workspace(repo_id, workspace_id)
1418 1418 except NotImplementedError:
1419 1419 pass
1420 1420
1421 1421 def _get_repo_pullrequest_sources(
1422 1422 self, repo, commit_id=None, branch=None, bookmark=None,
1423 1423 translator=None):
1424 1424 """
1425 1425 Return a structure with repo's interesting commits, suitable for
1426 1426 the selectors in pullrequest controller
1427 1427
1428 1428 :param commit_id: a commit that must be in the list somehow
1429 1429 and selected by default
1430 1430 :param branch: a branch that must be in the list and selected
1431 1431 by default - even if closed
1432 1432 :param bookmark: a bookmark that must be in the list and selected
1433 1433 """
1434 1434 _ = translator or get_current_request().translate
1435 1435
1436 1436 commit_id = safe_str(commit_id) if commit_id else None
1437 1437 branch = safe_str(branch) if branch else None
1438 1438 bookmark = safe_str(bookmark) if bookmark else None
1439 1439
1440 1440 selected = None
1441 1441
1442 1442 # order matters: first source that has commit_id in it will be selected
1443 1443 sources = []
1444 1444 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1445 1445 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1446 1446
1447 1447 if commit_id:
1448 1448 ref_commit = (h.short_id(commit_id), commit_id)
1449 1449 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1450 1450
1451 1451 sources.append(
1452 1452 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1453 1453 )
1454 1454
1455 1455 groups = []
1456 1456 for group_key, ref_list, group_name, match in sources:
1457 1457 group_refs = []
1458 1458 for ref_name, ref_id in ref_list:
1459 1459 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1460 1460 group_refs.append((ref_key, ref_name))
1461 1461
1462 1462 if not selected:
1463 1463 if set([commit_id, match]) & set([ref_id, ref_name]):
1464 1464 selected = ref_key
1465 1465
1466 1466 if group_refs:
1467 1467 groups.append((group_refs, group_name))
1468 1468
1469 1469 if not selected:
1470 1470 ref = commit_id or branch or bookmark
1471 1471 if ref:
1472 1472 raise CommitDoesNotExistError(
1473 1473 'No commit refs could be found matching: %s' % ref)
1474 1474 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1475 1475 selected = 'branch:%s:%s' % (
1476 1476 repo.DEFAULT_BRANCH_NAME,
1477 1477 repo.branches[repo.DEFAULT_BRANCH_NAME]
1478 1478 )
1479 1479 elif repo.commit_ids:
1480 1480 # make the user select in this case
1481 1481 selected = None
1482 1482 else:
1483 1483 raise EmptyRepositoryError()
1484 1484 return groups, selected
1485 1485
1486 1486 def get_diff(self, source_repo, source_ref_id, target_ref_id, context=DIFF_CONTEXT):
1487 1487 return self._get_diff_from_pr_or_version(
1488 1488 source_repo, source_ref_id, target_ref_id, context=context)
1489 1489
1490 1490 def _get_diff_from_pr_or_version(
1491 1491 self, source_repo, source_ref_id, target_ref_id, context):
1492 1492 target_commit = source_repo.get_commit(
1493 1493 commit_id=safe_str(target_ref_id))
1494 1494 source_commit = source_repo.get_commit(
1495 1495 commit_id=safe_str(source_ref_id))
1496 1496 if isinstance(source_repo, Repository):
1497 1497 vcs_repo = source_repo.scm_instance()
1498 1498 else:
1499 1499 vcs_repo = source_repo
1500 1500
1501 1501 # TODO: johbo: In the context of an update, we cannot reach
1502 1502 # the old commit anymore with our normal mechanisms. It needs
1503 1503 # some sort of special support in the vcs layer to avoid this
1504 1504 # workaround.
1505 1505 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1506 1506 vcs_repo.alias == 'git'):
1507 1507 source_commit.raw_id = safe_str(source_ref_id)
1508 1508
1509 1509 log.debug('calculating diff between '
1510 1510 'source_ref:%s and target_ref:%s for repo `%s`',
1511 1511 target_ref_id, source_ref_id,
1512 1512 safe_unicode(vcs_repo.path))
1513 1513
1514 1514 vcs_diff = vcs_repo.get_diff(
1515 1515 commit1=target_commit, commit2=source_commit, context=context)
1516 1516 return vcs_diff
1517 1517
1518 1518 def _is_merge_enabled(self, pull_request):
1519 1519 return self._get_general_setting(
1520 1520 pull_request, 'rhodecode_pr_merge_enabled')
1521 1521
1522 1522 def _use_rebase_for_merging(self, pull_request):
1523 1523 repo_type = pull_request.target_repo.repo_type
1524 1524 if repo_type == 'hg':
1525 1525 return self._get_general_setting(
1526 1526 pull_request, 'rhodecode_hg_use_rebase_for_merging')
1527 1527 elif repo_type == 'git':
1528 1528 return self._get_general_setting(
1529 1529 pull_request, 'rhodecode_git_use_rebase_for_merging')
1530 1530
1531 1531 return False
1532 1532
1533 1533 def _close_branch_before_merging(self, pull_request):
1534 1534 repo_type = pull_request.target_repo.repo_type
1535 1535 if repo_type == 'hg':
1536 1536 return self._get_general_setting(
1537 1537 pull_request, 'rhodecode_hg_close_branch_before_merging')
1538 1538 elif repo_type == 'git':
1539 1539 return self._get_general_setting(
1540 1540 pull_request, 'rhodecode_git_close_branch_before_merging')
1541 1541
1542 1542 return False
1543 1543
1544 1544 def _get_general_setting(self, pull_request, settings_key, default=False):
1545 1545 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1546 1546 settings = settings_model.get_general_settings()
1547 1547 return settings.get(settings_key, default)
1548 1548
1549 1549 def _log_audit_action(self, action, action_data, user, pull_request):
1550 1550 audit_logger.store(
1551 1551 action=action,
1552 1552 action_data=action_data,
1553 1553 user=user,
1554 1554 repo=pull_request.target_repo)
1555 1555
1556 1556 def get_reviewer_functions(self):
1557 1557 """
1558 1558 Fetches functions for validation and fetching default reviewers.
1559 1559 If available we use the EE package, else we fallback to CE
1560 1560 package functions
1561 1561 """
1562 1562 try:
1563 1563 from rc_reviewers.utils import get_default_reviewers_data
1564 1564 from rc_reviewers.utils import validate_default_reviewers
1565 1565 except ImportError:
1566 1566 from rhodecode.apps.repository.utils import \
1567 1567 get_default_reviewers_data
1568 1568 from rhodecode.apps.repository.utils import \
1569 1569 validate_default_reviewers
1570 1570
1571 1571 return get_default_reviewers_data, validate_default_reviewers
1572 1572
1573 1573
1574 1574 class MergeCheck(object):
1575 1575 """
1576 1576 Perform Merge Checks and returns a check object which stores information
1577 1577 about merge errors, and merge conditions
1578 1578 """
1579 1579 TODO_CHECK = 'todo'
1580 1580 PERM_CHECK = 'perm'
1581 1581 REVIEW_CHECK = 'review'
1582 1582 MERGE_CHECK = 'merge'
1583 1583
1584 1584 def __init__(self):
1585 1585 self.review_status = None
1586 1586 self.merge_possible = None
1587 1587 self.merge_msg = ''
1588 1588 self.failed = None
1589 1589 self.errors = []
1590 1590 self.error_details = OrderedDict()
1591 1591
1592 1592 def push_error(self, error_type, message, error_key, details):
1593 1593 self.failed = True
1594 1594 self.errors.append([error_type, message])
1595 1595 self.error_details[error_key] = dict(
1596 1596 details=details,
1597 1597 error_type=error_type,
1598 1598 message=message
1599 1599 )
1600 1600
1601 1601 @classmethod
1602 def validate(cls, pull_request, user, translator, fail_early=False,
1602 def validate(cls, pull_request, auth_user, translator, fail_early=False,
1603 1603 force_shadow_repo_refresh=False):
1604 1604 _ = translator
1605 1605 merge_check = cls()
1606 1606
1607 1607 # permissions to merge
1608 1608 user_allowed_to_merge = PullRequestModel().check_user_merge(
1609 pull_request, user)
1609 pull_request, auth_user)
1610 1610 if not user_allowed_to_merge:
1611 1611 log.debug("MergeCheck: cannot merge, approval is pending.")
1612 1612
1613 msg = _('User `{}` not allowed to perform merge.').format(user.username)
1614 merge_check.push_error('error', msg, cls.PERM_CHECK, user.username)
1613 msg = _('User `{}` not allowed to perform merge.').format(auth_user.username)
1614 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1615 if fail_early:
1616 return merge_check
1617
1618 # permission to merge into the target branch
1619 target_commit_id = pull_request.target_ref_parts.commit_id
1620 if pull_request.target_ref_parts.type == 'branch':
1621 branch_name = pull_request.target_ref_parts.name
1622 else:
1623 # for mercurial we can always figure out the branch from the commit
1624 # in case of bookmark
1625 target_commit = pull_request.target_repo.get_commit(target_commit_id)
1626 branch_name = target_commit.branch
1627
1628 rule, branch_perm = auth_user.get_rule_and_branch_permission(
1629 pull_request.target_repo.repo_name, branch_name)
1630 if branch_perm and branch_perm == 'branch.none':
1631 msg = _('Target branch `{}` changes rejected by rule {}.').format(
1632 branch_name, rule)
1633 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1615 1634 if fail_early:
1616 1635 return merge_check
1617 1636
1618 1637 # review status, must be always present
1619 1638 review_status = pull_request.calculated_review_status()
1620 1639 merge_check.review_status = review_status
1621 1640
1622 1641 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1623 1642 if not status_approved:
1624 1643 log.debug("MergeCheck: cannot merge, approval is pending.")
1625 1644
1626 1645 msg = _('Pull request reviewer approval is pending.')
1627 1646
1628 1647 merge_check.push_error(
1629 1648 'warning', msg, cls.REVIEW_CHECK, review_status)
1630 1649
1631 1650 if fail_early:
1632 1651 return merge_check
1633 1652
1634 1653 # left over TODOs
1635 1654 todos = CommentsModel().get_unresolved_todos(pull_request)
1636 1655 if todos:
1637 1656 log.debug("MergeCheck: cannot merge, {} "
1638 1657 "unresolved todos left.".format(len(todos)))
1639 1658
1640 1659 if len(todos) == 1:
1641 1660 msg = _('Cannot merge, {} TODO still not resolved.').format(
1642 1661 len(todos))
1643 1662 else:
1644 1663 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1645 1664 len(todos))
1646 1665
1647 1666 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1648 1667
1649 1668 if fail_early:
1650 1669 return merge_check
1651 1670
1652 1671 # merge possible, here is the filesystem simulation + shadow repo
1653 1672 merge_status, msg = PullRequestModel().merge_status(
1654 1673 pull_request, translator=translator,
1655 1674 force_shadow_repo_refresh=force_shadow_repo_refresh)
1656 1675 merge_check.merge_possible = merge_status
1657 1676 merge_check.merge_msg = msg
1658 1677 if not merge_status:
1659 1678 log.debug(
1660 1679 "MergeCheck: cannot merge, pull request merge not possible.")
1661 1680 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1662 1681
1663 1682 if fail_early:
1664 1683 return merge_check
1665 1684
1666 1685 log.debug('MergeCheck: is failed: %s', merge_check.failed)
1667 1686 return merge_check
1668 1687
1669 1688 @classmethod
1670 1689 def get_merge_conditions(cls, pull_request, translator):
1671 1690 _ = translator
1672 1691 merge_details = {}
1673 1692
1674 1693 model = PullRequestModel()
1675 1694 use_rebase = model._use_rebase_for_merging(pull_request)
1676 1695
1677 1696 if use_rebase:
1678 1697 merge_details['merge_strategy'] = dict(
1679 1698 details={},
1680 1699 message=_('Merge strategy: rebase')
1681 1700 )
1682 1701 else:
1683 1702 merge_details['merge_strategy'] = dict(
1684 1703 details={},
1685 1704 message=_('Merge strategy: explicit merge commit')
1686 1705 )
1687 1706
1688 1707 close_branch = model._close_branch_before_merging(pull_request)
1689 1708 if close_branch:
1690 1709 repo_type = pull_request.target_repo.repo_type
1691 1710 if repo_type == 'hg':
1692 1711 close_msg = _('Source branch will be closed after merge.')
1693 1712 elif repo_type == 'git':
1694 1713 close_msg = _('Source branch will be deleted after merge.')
1695 1714
1696 1715 merge_details['close_branch'] = dict(
1697 1716 details={},
1698 1717 message=close_msg
1699 1718 )
1700 1719
1701 1720 return merge_details
1702 1721
1703 1722 ChangeTuple = collections.namedtuple(
1704 1723 'ChangeTuple', ['added', 'common', 'removed', 'total'])
1705 1724
1706 1725 FileChangeTuple = collections.namedtuple(
1707 1726 'FileChangeTuple', ['added', 'modified', 'removed'])
General Comments 0
You need to be logged in to leave comments. Login now