##// END OF EJS Templates
comments: renamed ChangesetCommentsModel to CommentsModel to reflect what it actually does....
marcink -
r1323:41333240 default
parent child Browse files
Show More
@@ -1,209 +1,209 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 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 from rhodecode.model.comment import ChangesetCommentsModel
23 from rhodecode.model.comment import CommentsModel
24 24 from rhodecode.model.db import UserLog
25 25 from rhodecode.model.pull_request import PullRequestModel
26 26 from rhodecode.tests import TEST_USER_ADMIN_LOGIN
27 27 from rhodecode.api.tests.utils import (
28 28 build_data, api_call, assert_error, assert_ok)
29 29
30 30
31 31 @pytest.mark.usefixtures("testuser_api", "app")
32 32 class TestCommentPullRequest(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 @pytest.mark.backends("git", "hg")
42 42 def test_api_comment_pull_request(self, pr_util, no_notifications):
43 43 pull_request = pr_util.create_pull_request()
44 44 pull_request_id = pull_request.pull_request_id
45 45 author = pull_request.user_id
46 46 repo = pull_request.target_repo.repo_id
47 47 id_, params = build_data(
48 48 self.apikey, 'comment_pull_request',
49 49 repoid=pull_request.target_repo.repo_name,
50 50 pullrequestid=pull_request.pull_request_id,
51 51 message='test message')
52 52 response = api_call(self.app, params)
53 53 pull_request = PullRequestModel().get(pull_request.pull_request_id)
54 54
55 comments = ChangesetCommentsModel().get_comments(
55 comments = CommentsModel().get_comments(
56 56 pull_request.target_repo.repo_id, pull_request=pull_request)
57 57
58 58 expected = {
59 59 'pull_request_id': pull_request.pull_request_id,
60 60 'comment_id': comments[-1].comment_id,
61 61 'status': {'given': None, 'was_changed': None}
62 62 }
63 63 assert_ok(id_, expected, response.body)
64 64
65 65 action = 'user_commented_pull_request:%d' % pull_request_id
66 66 journal = UserLog.query()\
67 67 .filter(UserLog.user_id == author)\
68 68 .filter(UserLog.repository_id == repo)\
69 69 .filter(UserLog.action == action)\
70 70 .all()
71 71 assert len(journal) == 2
72 72
73 73 @pytest.mark.backends("git", "hg")
74 74 def test_api_comment_pull_request_change_status(
75 75 self, pr_util, no_notifications):
76 76 pull_request = pr_util.create_pull_request()
77 77 pull_request_id = pull_request.pull_request_id
78 78 id_, params = build_data(
79 79 self.apikey, 'comment_pull_request',
80 80 repoid=pull_request.target_repo.repo_name,
81 81 pullrequestid=pull_request.pull_request_id,
82 82 status='rejected')
83 83 response = api_call(self.app, params)
84 84 pull_request = PullRequestModel().get(pull_request_id)
85 85
86 comments = ChangesetCommentsModel().get_comments(
86 comments = CommentsModel().get_comments(
87 87 pull_request.target_repo.repo_id, pull_request=pull_request)
88 88 expected = {
89 89 'pull_request_id': pull_request.pull_request_id,
90 90 'comment_id': comments[-1].comment_id,
91 91 'status': {'given': 'rejected', 'was_changed': True}
92 92 }
93 93 assert_ok(id_, expected, response.body)
94 94
95 95 @pytest.mark.backends("git", "hg")
96 96 def test_api_comment_pull_request_change_status_with_specific_commit_id(
97 97 self, pr_util, no_notifications):
98 98 pull_request = pr_util.create_pull_request()
99 99 pull_request_id = pull_request.pull_request_id
100 100 latest_commit_id = 'test_commit'
101 101 # inject additional revision, to fail test the status change on
102 102 # non-latest commit
103 103 pull_request.revisions = pull_request.revisions + ['test_commit']
104 104
105 105 id_, params = build_data(
106 106 self.apikey, 'comment_pull_request',
107 107 repoid=pull_request.target_repo.repo_name,
108 108 pullrequestid=pull_request.pull_request_id,
109 109 status='approved', commit_id=latest_commit_id)
110 110 response = api_call(self.app, params)
111 111 pull_request = PullRequestModel().get(pull_request_id)
112 112
113 113 expected = {
114 114 'pull_request_id': pull_request.pull_request_id,
115 115 'comment_id': None,
116 116 'status': {'given': 'approved', 'was_changed': False}
117 117 }
118 118 assert_ok(id_, expected, response.body)
119 119
120 120 @pytest.mark.backends("git", "hg")
121 121 def test_api_comment_pull_request_change_status_with_specific_commit_id(
122 122 self, pr_util, no_notifications):
123 123 pull_request = pr_util.create_pull_request()
124 124 pull_request_id = pull_request.pull_request_id
125 125 latest_commit_id = pull_request.revisions[0]
126 126
127 127 id_, params = build_data(
128 128 self.apikey, 'comment_pull_request',
129 129 repoid=pull_request.target_repo.repo_name,
130 130 pullrequestid=pull_request.pull_request_id,
131 131 status='approved', commit_id=latest_commit_id)
132 132 response = api_call(self.app, params)
133 133 pull_request = PullRequestModel().get(pull_request_id)
134 134
135 comments = ChangesetCommentsModel().get_comments(
135 comments = CommentsModel().get_comments(
136 136 pull_request.target_repo.repo_id, pull_request=pull_request)
137 137 expected = {
138 138 'pull_request_id': pull_request.pull_request_id,
139 139 'comment_id': comments[-1].comment_id,
140 140 'status': {'given': 'approved', 'was_changed': True}
141 141 }
142 142 assert_ok(id_, expected, response.body)
143 143
144 144 @pytest.mark.backends("git", "hg")
145 145 def test_api_comment_pull_request_missing_params_error(self, pr_util):
146 146 pull_request = pr_util.create_pull_request()
147 147 pull_request_id = pull_request.pull_request_id
148 148 pull_request_repo = pull_request.target_repo.repo_name
149 149 id_, params = build_data(
150 150 self.apikey, 'comment_pull_request',
151 151 repoid=pull_request_repo,
152 152 pullrequestid=pull_request_id)
153 153 response = api_call(self.app, params)
154 154
155 155 expected = 'Both message and status parameters are missing. At least one is required.'
156 156 assert_error(id_, expected, given=response.body)
157 157
158 158 @pytest.mark.backends("git", "hg")
159 159 def test_api_comment_pull_request_unknown_status_error(self, pr_util):
160 160 pull_request = pr_util.create_pull_request()
161 161 pull_request_id = pull_request.pull_request_id
162 162 pull_request_repo = pull_request.target_repo.repo_name
163 163 id_, params = build_data(
164 164 self.apikey, 'comment_pull_request',
165 165 repoid=pull_request_repo,
166 166 pullrequestid=pull_request_id,
167 167 status='42')
168 168 response = api_call(self.app, params)
169 169
170 170 expected = 'Unknown comment status: `42`'
171 171 assert_error(id_, expected, given=response.body)
172 172
173 173 @pytest.mark.backends("git", "hg")
174 174 def test_api_comment_pull_request_repo_error(self):
175 175 id_, params = build_data(
176 176 self.apikey, 'comment_pull_request',
177 177 repoid=666, pullrequestid=1)
178 178 response = api_call(self.app, params)
179 179
180 180 expected = 'repository `666` does not exist'
181 181 assert_error(id_, expected, given=response.body)
182 182
183 183 @pytest.mark.backends("git", "hg")
184 184 def test_api_comment_pull_request_non_admin_with_userid_error(
185 185 self, pr_util):
186 186 pull_request = pr_util.create_pull_request()
187 187 id_, params = build_data(
188 188 self.apikey_regular, 'comment_pull_request',
189 189 repoid=pull_request.target_repo.repo_name,
190 190 pullrequestid=pull_request.pull_request_id,
191 191 userid=TEST_USER_ADMIN_LOGIN)
192 192 response = api_call(self.app, params)
193 193
194 194 expected = 'userid is not the same as your user'
195 195 assert_error(id_, expected, given=response.body)
196 196
197 197 @pytest.mark.backends("git", "hg")
198 198 def test_api_comment_pull_request_wrong_commit_id_error(self, pr_util):
199 199 pull_request = pr_util.create_pull_request()
200 200 id_, params = build_data(
201 201 self.apikey_regular, 'comment_pull_request',
202 202 repoid=pull_request.target_repo.repo_name,
203 203 status='approved',
204 204 pullrequestid=pull_request.pull_request_id,
205 205 commit_id='XXX')
206 206 response = api_call(self.app, params)
207 207
208 208 expected = 'Invalid commit_id `XXX` for this pull request.'
209 209 assert_error(id_, expected, given=response.body)
@@ -1,714 +1,714 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2017 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.api import jsonrpc_method, JSONRPCError
25 25 from rhodecode.api.utils import (
26 26 has_superadmin_permission, Optional, OAttr, get_repo_or_error,
27 27 get_pull_request_or_error, get_commit_or_error, get_user_or_error,
28 28 validate_repo_permissions, resolve_ref_or_error)
29 29 from rhodecode.lib.auth import (HasRepoPermissionAnyApi)
30 30 from rhodecode.lib.base import vcs_operation_context
31 31 from rhodecode.lib.utils2 import str2bool
32 32 from rhodecode.model.changeset_status import ChangesetStatusModel
33 from rhodecode.model.comment import ChangesetCommentsModel
33 from rhodecode.model.comment import CommentsModel
34 34 from rhodecode.model.db import Session, ChangesetStatus
35 35 from rhodecode.model.pull_request import PullRequestModel
36 36 from rhodecode.model.settings import SettingsModel
37 37
38 38 log = logging.getLogger(__name__)
39 39
40 40
41 41 @jsonrpc_method()
42 42 def get_pull_request(request, apiuser, repoid, pullrequestid):
43 43 """
44 44 Get a pull request based on the given ID.
45 45
46 46 :param apiuser: This is filled automatically from the |authtoken|.
47 47 :type apiuser: AuthUser
48 48 :param repoid: Repository name or repository ID from where the pull
49 49 request was opened.
50 50 :type repoid: str or int
51 51 :param pullrequestid: ID of the requested pull request.
52 52 :type pullrequestid: int
53 53
54 54 Example output:
55 55
56 56 .. code-block:: bash
57 57
58 58 "id": <id_given_in_input>,
59 59 "result":
60 60 {
61 61 "pull_request_id": "<pull_request_id>",
62 62 "url": "<url>",
63 63 "title": "<title>",
64 64 "description": "<description>",
65 65 "status" : "<status>",
66 66 "created_on": "<date_time_created>",
67 67 "updated_on": "<date_time_updated>",
68 68 "commit_ids": [
69 69 ...
70 70 "<commit_id>",
71 71 "<commit_id>",
72 72 ...
73 73 ],
74 74 "review_status": "<review_status>",
75 75 "mergeable": {
76 76 "status": "<bool>",
77 77 "message": "<message>",
78 78 },
79 79 "source": {
80 80 "clone_url": "<clone_url>",
81 81 "repository": "<repository_name>",
82 82 "reference":
83 83 {
84 84 "name": "<name>",
85 85 "type": "<type>",
86 86 "commit_id": "<commit_id>",
87 87 }
88 88 },
89 89 "target": {
90 90 "clone_url": "<clone_url>",
91 91 "repository": "<repository_name>",
92 92 "reference":
93 93 {
94 94 "name": "<name>",
95 95 "type": "<type>",
96 96 "commit_id": "<commit_id>",
97 97 }
98 98 },
99 99 "merge": {
100 100 "clone_url": "<clone_url>",
101 101 "reference":
102 102 {
103 103 "name": "<name>",
104 104 "type": "<type>",
105 105 "commit_id": "<commit_id>",
106 106 }
107 107 },
108 108 "author": <user_obj>,
109 109 "reviewers": [
110 110 ...
111 111 {
112 112 "user": "<user_obj>",
113 113 "review_status": "<review_status>",
114 114 }
115 115 ...
116 116 ]
117 117 },
118 118 "error": null
119 119 """
120 120 get_repo_or_error(repoid)
121 121 pull_request = get_pull_request_or_error(pullrequestid)
122 122 if not PullRequestModel().check_user_read(
123 123 pull_request, apiuser, api=True):
124 124 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
125 125 data = pull_request.get_api_data()
126 126 return data
127 127
128 128
129 129 @jsonrpc_method()
130 130 def get_pull_requests(request, apiuser, repoid, status=Optional('new')):
131 131 """
132 132 Get all pull requests from the repository specified in `repoid`.
133 133
134 134 :param apiuser: This is filled automatically from the |authtoken|.
135 135 :type apiuser: AuthUser
136 136 :param repoid: Repository name or repository ID.
137 137 :type repoid: str or int
138 138 :param status: Only return pull requests with the specified status.
139 139 Valid options are.
140 140 * ``new`` (default)
141 141 * ``open``
142 142 * ``closed``
143 143 :type status: str
144 144
145 145 Example output:
146 146
147 147 .. code-block:: bash
148 148
149 149 "id": <id_given_in_input>,
150 150 "result":
151 151 [
152 152 ...
153 153 {
154 154 "pull_request_id": "<pull_request_id>",
155 155 "url": "<url>",
156 156 "title" : "<title>",
157 157 "description": "<description>",
158 158 "status": "<status>",
159 159 "created_on": "<date_time_created>",
160 160 "updated_on": "<date_time_updated>",
161 161 "commit_ids": [
162 162 ...
163 163 "<commit_id>",
164 164 "<commit_id>",
165 165 ...
166 166 ],
167 167 "review_status": "<review_status>",
168 168 "mergeable": {
169 169 "status": "<bool>",
170 170 "message: "<message>",
171 171 },
172 172 "source": {
173 173 "clone_url": "<clone_url>",
174 174 "reference":
175 175 {
176 176 "name": "<name>",
177 177 "type": "<type>",
178 178 "commit_id": "<commit_id>",
179 179 }
180 180 },
181 181 "target": {
182 182 "clone_url": "<clone_url>",
183 183 "reference":
184 184 {
185 185 "name": "<name>",
186 186 "type": "<type>",
187 187 "commit_id": "<commit_id>",
188 188 }
189 189 },
190 190 "merge": {
191 191 "clone_url": "<clone_url>",
192 192 "reference":
193 193 {
194 194 "name": "<name>",
195 195 "type": "<type>",
196 196 "commit_id": "<commit_id>",
197 197 }
198 198 },
199 199 "author": <user_obj>,
200 200 "reviewers": [
201 201 ...
202 202 {
203 203 "user": "<user_obj>",
204 204 "review_status": "<review_status>",
205 205 }
206 206 ...
207 207 ]
208 208 }
209 209 ...
210 210 ],
211 211 "error": null
212 212
213 213 """
214 214 repo = get_repo_or_error(repoid)
215 215 if not has_superadmin_permission(apiuser):
216 216 _perms = (
217 217 'repository.admin', 'repository.write', 'repository.read',)
218 218 validate_repo_permissions(apiuser, repoid, repo, _perms)
219 219
220 220 status = Optional.extract(status)
221 221 pull_requests = PullRequestModel().get_all(repo, statuses=[status])
222 222 data = [pr.get_api_data() for pr in pull_requests]
223 223 return data
224 224
225 225
226 226 @jsonrpc_method()
227 227 def merge_pull_request(request, apiuser, repoid, pullrequestid,
228 228 userid=Optional(OAttr('apiuser'))):
229 229 """
230 230 Merge the pull request specified by `pullrequestid` into its target
231 231 repository.
232 232
233 233 :param apiuser: This is filled automatically from the |authtoken|.
234 234 :type apiuser: AuthUser
235 235 :param repoid: The Repository name or repository ID of the
236 236 target repository to which the |pr| is to be merged.
237 237 :type repoid: str or int
238 238 :param pullrequestid: ID of the pull request which shall be merged.
239 239 :type pullrequestid: int
240 240 :param userid: Merge the pull request as this user.
241 241 :type userid: Optional(str or int)
242 242
243 243 Example output:
244 244
245 245 .. code-block:: bash
246 246
247 247 "id": <id_given_in_input>,
248 248 "result":
249 249 {
250 250 "executed": "<bool>",
251 251 "failure_reason": "<int>",
252 252 "merge_commit_id": "<merge_commit_id>",
253 253 "possible": "<bool>",
254 254 "merge_ref": {
255 255 "commit_id": "<commit_id>",
256 256 "type": "<type>",
257 257 "name": "<name>"
258 258 }
259 259 },
260 260 "error": null
261 261
262 262 """
263 263 repo = get_repo_or_error(repoid)
264 264 if not isinstance(userid, Optional):
265 265 if (has_superadmin_permission(apiuser) or
266 266 HasRepoPermissionAnyApi('repository.admin')(
267 267 user=apiuser, repo_name=repo.repo_name)):
268 268 apiuser = get_user_or_error(userid)
269 269 else:
270 270 raise JSONRPCError('userid is not the same as your user')
271 271
272 272 pull_request = get_pull_request_or_error(pullrequestid)
273 273 if not PullRequestModel().check_user_merge(
274 274 pull_request, apiuser, api=True):
275 275 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
276 276 if pull_request.is_closed():
277 277 raise JSONRPCError(
278 278 'pull request `%s` merge failed, pull request is closed' % (
279 279 pullrequestid,))
280 280
281 281 target_repo = pull_request.target_repo
282 282 extras = vcs_operation_context(
283 283 request.environ, repo_name=target_repo.repo_name,
284 284 username=apiuser.username, action='push',
285 285 scm=target_repo.repo_type)
286 286 merge_response = PullRequestModel().merge(
287 287 pull_request, apiuser, extras=extras)
288 288 if merge_response.executed:
289 289 PullRequestModel().close_pull_request(
290 290 pull_request.pull_request_id, apiuser)
291 291
292 292 Session().commit()
293 293
294 294 # In previous versions the merge response directly contained the merge
295 295 # commit id. It is now contained in the merge reference object. To be
296 296 # backwards compatible we have to extract it again.
297 297 merge_response = merge_response._asdict()
298 298 merge_response['merge_commit_id'] = merge_response['merge_ref'].commit_id
299 299
300 300 return merge_response
301 301
302 302
303 303 @jsonrpc_method()
304 304 def close_pull_request(request, apiuser, repoid, pullrequestid,
305 305 userid=Optional(OAttr('apiuser'))):
306 306 """
307 307 Close the pull request specified by `pullrequestid`.
308 308
309 309 :param apiuser: This is filled automatically from the |authtoken|.
310 310 :type apiuser: AuthUser
311 311 :param repoid: Repository name or repository ID to which the pull
312 312 request belongs.
313 313 :type repoid: str or int
314 314 :param pullrequestid: ID of the pull request to be closed.
315 315 :type pullrequestid: int
316 316 :param userid: Close the pull request as this user.
317 317 :type userid: Optional(str or int)
318 318
319 319 Example output:
320 320
321 321 .. code-block:: bash
322 322
323 323 "id": <id_given_in_input>,
324 324 "result":
325 325 {
326 326 "pull_request_id": "<int>",
327 327 "closed": "<bool>"
328 328 },
329 329 "error": null
330 330
331 331 """
332 332 repo = get_repo_or_error(repoid)
333 333 if not isinstance(userid, Optional):
334 334 if (has_superadmin_permission(apiuser) or
335 335 HasRepoPermissionAnyApi('repository.admin')(
336 336 user=apiuser, repo_name=repo.repo_name)):
337 337 apiuser = get_user_or_error(userid)
338 338 else:
339 339 raise JSONRPCError('userid is not the same as your user')
340 340
341 341 pull_request = get_pull_request_or_error(pullrequestid)
342 342 if not PullRequestModel().check_user_update(
343 343 pull_request, apiuser, api=True):
344 344 raise JSONRPCError(
345 345 'pull request `%s` close failed, no permission to close.' % (
346 346 pullrequestid,))
347 347 if pull_request.is_closed():
348 348 raise JSONRPCError(
349 349 'pull request `%s` is already closed' % (pullrequestid,))
350 350
351 351 PullRequestModel().close_pull_request(
352 352 pull_request.pull_request_id, apiuser)
353 353 Session().commit()
354 354 data = {
355 355 'pull_request_id': pull_request.pull_request_id,
356 356 'closed': True,
357 357 }
358 358 return data
359 359
360 360
361 361 @jsonrpc_method()
362 362 def comment_pull_request(request, apiuser, repoid, pullrequestid,
363 363 message=Optional(None), status=Optional(None),
364 364 commit_id=Optional(None),
365 365 userid=Optional(OAttr('apiuser'))):
366 366 """
367 367 Comment on the pull request specified with the `pullrequestid`,
368 368 in the |repo| specified by the `repoid`, and optionally change the
369 369 review status.
370 370
371 371 :param apiuser: This is filled automatically from the |authtoken|.
372 372 :type apiuser: AuthUser
373 373 :param repoid: The repository name or repository ID.
374 374 :type repoid: str or int
375 375 :param pullrequestid: The pull request ID.
376 376 :type pullrequestid: int
377 377 :param message: The text content of the comment.
378 378 :type message: str
379 379 :param status: (**Optional**) Set the approval status of the pull
380 380 request. Valid options are:
381 381 * not_reviewed
382 382 * approved
383 383 * rejected
384 384 * under_review
385 385 :type status: str
386 386 :param commit_id: Specify the commit_id for which to set a comment. If
387 387 given commit_id is different than latest in the PR status
388 388 change won't be performed.
389 389 :type commit_id: str
390 390 :param userid: Comment on the pull request as this user
391 391 :type userid: Optional(str or int)
392 392
393 393 Example output:
394 394
395 395 .. code-block:: bash
396 396
397 397 id : <id_given_in_input>
398 398 result :
399 399 {
400 400 "pull_request_id": "<Integer>",
401 401 "comment_id": "<Integer>",
402 402 "status": {"given": <given_status>,
403 403 "was_changed": <bool status_was_actually_changed> },
404 404 }
405 405 error : null
406 406 """
407 407 repo = get_repo_or_error(repoid)
408 408 if not isinstance(userid, Optional):
409 409 if (has_superadmin_permission(apiuser) or
410 410 HasRepoPermissionAnyApi('repository.admin')(
411 411 user=apiuser, repo_name=repo.repo_name)):
412 412 apiuser = get_user_or_error(userid)
413 413 else:
414 414 raise JSONRPCError('userid is not the same as your user')
415 415
416 416 pull_request = get_pull_request_or_error(pullrequestid)
417 417 if not PullRequestModel().check_user_read(
418 418 pull_request, apiuser, api=True):
419 419 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
420 420 message = Optional.extract(message)
421 421 status = Optional.extract(status)
422 422 commit_id = Optional.extract(commit_id)
423 423
424 424 if not message and not status:
425 425 raise JSONRPCError(
426 426 'Both message and status parameters are missing. '
427 427 'At least one is required.')
428 428
429 429 if (status not in (st[0] for st in ChangesetStatus.STATUSES) and
430 430 status is not None):
431 431 raise JSONRPCError('Unknown comment status: `%s`' % status)
432 432
433 433 if commit_id and commit_id not in pull_request.revisions:
434 434 raise JSONRPCError(
435 435 'Invalid commit_id `%s` for this pull request.' % commit_id)
436 436
437 437 allowed_to_change_status = PullRequestModel().check_user_change_status(
438 438 pull_request, apiuser)
439 439
440 440 # if commit_id is passed re-validated if user is allowed to change status
441 441 # based on latest commit_id from the PR
442 442 if commit_id:
443 443 commit_idx = pull_request.revisions.index(commit_id)
444 444 if commit_idx != 0:
445 445 allowed_to_change_status = False
446 446
447 447 text = message
448 448 status_label = ChangesetStatus.get_status_lbl(status)
449 449 if status and allowed_to_change_status:
450 450 st_message = ('Status change %(transition_icon)s %(status)s'
451 451 % {'transition_icon': '>', 'status': status_label})
452 452 text = message or st_message
453 453
454 454 rc_config = SettingsModel().get_all_settings()
455 455 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
456 456
457 457 status_change = status and allowed_to_change_status
458 comment = ChangesetCommentsModel().create(
458 comment = CommentsModel().create(
459 459 text=text,
460 460 repo=pull_request.target_repo.repo_id,
461 461 user=apiuser.user_id,
462 462 pull_request=pull_request.pull_request_id,
463 463 f_path=None,
464 464 line_no=None,
465 465 status_change=(status_label if status_change else None),
466 466 status_change_type=(status if status_change else None),
467 467 closing_pr=False,
468 468 renderer=renderer
469 469 )
470 470
471 471 if allowed_to_change_status and status:
472 472 ChangesetStatusModel().set_status(
473 473 pull_request.target_repo.repo_id,
474 474 status,
475 475 apiuser.user_id,
476 476 comment,
477 477 pull_request=pull_request.pull_request_id
478 478 )
479 479 Session().flush()
480 480
481 481 Session().commit()
482 482 data = {
483 483 'pull_request_id': pull_request.pull_request_id,
484 484 'comment_id': comment.comment_id if comment else None,
485 485 'status': {'given': status, 'was_changed': status_change},
486 486 }
487 487 return data
488 488
489 489
490 490 @jsonrpc_method()
491 491 def create_pull_request(
492 492 request, apiuser, source_repo, target_repo, source_ref, target_ref,
493 493 title, description=Optional(''), reviewers=Optional(None)):
494 494 """
495 495 Creates a new pull request.
496 496
497 497 Accepts refs in the following formats:
498 498
499 499 * branch:<branch_name>:<sha>
500 500 * branch:<branch_name>
501 501 * bookmark:<bookmark_name>:<sha> (Mercurial only)
502 502 * bookmark:<bookmark_name> (Mercurial only)
503 503
504 504 :param apiuser: This is filled automatically from the |authtoken|.
505 505 :type apiuser: AuthUser
506 506 :param source_repo: Set the source repository name.
507 507 :type source_repo: str
508 508 :param target_repo: Set the target repository name.
509 509 :type target_repo: str
510 510 :param source_ref: Set the source ref name.
511 511 :type source_ref: str
512 512 :param target_ref: Set the target ref name.
513 513 :type target_ref: str
514 514 :param title: Set the pull request title.
515 515 :type title: str
516 516 :param description: Set the pull request description.
517 517 :type description: Optional(str)
518 518 :param reviewers: Set the new pull request reviewers list.
519 519 :type reviewers: Optional(list)
520 520 Accepts username strings or objects of the format:
521 521 {
522 522 'username': 'nick', 'reasons': ['original author']
523 523 }
524 524 """
525 525
526 526 source = get_repo_or_error(source_repo)
527 527 target = get_repo_or_error(target_repo)
528 528 if not has_superadmin_permission(apiuser):
529 529 _perms = ('repository.admin', 'repository.write', 'repository.read',)
530 530 validate_repo_permissions(apiuser, source_repo, source, _perms)
531 531
532 532 full_source_ref = resolve_ref_or_error(source_ref, source)
533 533 full_target_ref = resolve_ref_or_error(target_ref, target)
534 534 source_commit = get_commit_or_error(full_source_ref, source)
535 535 target_commit = get_commit_or_error(full_target_ref, target)
536 536 source_scm = source.scm_instance()
537 537 target_scm = target.scm_instance()
538 538
539 539 commit_ranges = target_scm.compare(
540 540 target_commit.raw_id, source_commit.raw_id, source_scm,
541 541 merge=True, pre_load=[])
542 542
543 543 ancestor = target_scm.get_common_ancestor(
544 544 target_commit.raw_id, source_commit.raw_id, source_scm)
545 545
546 546 if not commit_ranges:
547 547 raise JSONRPCError('no commits found')
548 548
549 549 if not ancestor:
550 550 raise JSONRPCError('no common ancestor found')
551 551
552 552 reviewer_objects = Optional.extract(reviewers) or []
553 553 if not isinstance(reviewer_objects, list):
554 554 raise JSONRPCError('reviewers should be specified as a list')
555 555
556 556 reviewers_reasons = []
557 557 for reviewer_object in reviewer_objects:
558 558 reviewer_reasons = []
559 559 if isinstance(reviewer_object, (basestring, int)):
560 560 reviewer_username = reviewer_object
561 561 else:
562 562 reviewer_username = reviewer_object['username']
563 563 reviewer_reasons = reviewer_object.get('reasons', [])
564 564
565 565 user = get_user_or_error(reviewer_username)
566 566 reviewers_reasons.append((user.user_id, reviewer_reasons))
567 567
568 568 pull_request_model = PullRequestModel()
569 569 pull_request = pull_request_model.create(
570 570 created_by=apiuser.user_id,
571 571 source_repo=source_repo,
572 572 source_ref=full_source_ref,
573 573 target_repo=target_repo,
574 574 target_ref=full_target_ref,
575 575 revisions=reversed(
576 576 [commit.raw_id for commit in reversed(commit_ranges)]),
577 577 reviewers=reviewers_reasons,
578 578 title=title,
579 579 description=Optional.extract(description)
580 580 )
581 581
582 582 Session().commit()
583 583 data = {
584 584 'msg': 'Created new pull request `{}`'.format(title),
585 585 'pull_request_id': pull_request.pull_request_id,
586 586 }
587 587 return data
588 588
589 589
590 590 @jsonrpc_method()
591 591 def update_pull_request(
592 592 request, apiuser, repoid, pullrequestid, title=Optional(''),
593 593 description=Optional(''), reviewers=Optional(None),
594 594 update_commits=Optional(None), close_pull_request=Optional(None)):
595 595 """
596 596 Updates a pull request.
597 597
598 598 :param apiuser: This is filled automatically from the |authtoken|.
599 599 :type apiuser: AuthUser
600 600 :param repoid: The repository name or repository ID.
601 601 :type repoid: str or int
602 602 :param pullrequestid: The pull request ID.
603 603 :type pullrequestid: int
604 604 :param title: Set the pull request title.
605 605 :type title: str
606 606 :param description: Update pull request description.
607 607 :type description: Optional(str)
608 608 :param reviewers: Update pull request reviewers list with new value.
609 609 :type reviewers: Optional(list)
610 610 :param update_commits: Trigger update of commits for this pull request
611 611 :type: update_commits: Optional(bool)
612 612 :param close_pull_request: Close this pull request with rejected state
613 613 :type: close_pull_request: Optional(bool)
614 614
615 615 Example output:
616 616
617 617 .. code-block:: bash
618 618
619 619 id : <id_given_in_input>
620 620 result :
621 621 {
622 622 "msg": "Updated pull request `63`",
623 623 "pull_request": <pull_request_object>,
624 624 "updated_reviewers": {
625 625 "added": [
626 626 "username"
627 627 ],
628 628 "removed": []
629 629 },
630 630 "updated_commits": {
631 631 "added": [
632 632 "<sha1_hash>"
633 633 ],
634 634 "common": [
635 635 "<sha1_hash>",
636 636 "<sha1_hash>",
637 637 ],
638 638 "removed": []
639 639 }
640 640 }
641 641 error : null
642 642 """
643 643
644 644 repo = get_repo_or_error(repoid)
645 645 pull_request = get_pull_request_or_error(pullrequestid)
646 646 if not PullRequestModel().check_user_update(
647 647 pull_request, apiuser, api=True):
648 648 raise JSONRPCError(
649 649 'pull request `%s` update failed, no permission to update.' % (
650 650 pullrequestid,))
651 651 if pull_request.is_closed():
652 652 raise JSONRPCError(
653 653 'pull request `%s` update failed, pull request is closed' % (
654 654 pullrequestid,))
655 655
656 656 reviewer_objects = Optional.extract(reviewers) or []
657 657 if not isinstance(reviewer_objects, list):
658 658 raise JSONRPCError('reviewers should be specified as a list')
659 659
660 660 reviewers_reasons = []
661 661 reviewer_ids = set()
662 662 for reviewer_object in reviewer_objects:
663 663 reviewer_reasons = []
664 664 if isinstance(reviewer_object, (int, basestring)):
665 665 reviewer_username = reviewer_object
666 666 else:
667 667 reviewer_username = reviewer_object['username']
668 668 reviewer_reasons = reviewer_object.get('reasons', [])
669 669
670 670 user = get_user_or_error(reviewer_username)
671 671 reviewer_ids.add(user.user_id)
672 672 reviewers_reasons.append((user.user_id, reviewer_reasons))
673 673
674 674 title = Optional.extract(title)
675 675 description = Optional.extract(description)
676 676 if title or description:
677 677 PullRequestModel().edit(
678 678 pull_request, title or pull_request.title,
679 679 description or pull_request.description)
680 680 Session().commit()
681 681
682 682 commit_changes = {"added": [], "common": [], "removed": []}
683 683 if str2bool(Optional.extract(update_commits)):
684 684 if PullRequestModel().has_valid_update_type(pull_request):
685 685 update_response = PullRequestModel().update_commits(
686 686 pull_request)
687 687 commit_changes = update_response.changes or commit_changes
688 688 Session().commit()
689 689
690 690 reviewers_changes = {"added": [], "removed": []}
691 691 if reviewer_ids:
692 692 added_reviewers, removed_reviewers = \
693 693 PullRequestModel().update_reviewers(pull_request, reviewers_reasons)
694 694
695 695 reviewers_changes['added'] = sorted(
696 696 [get_user_or_error(n).username for n in added_reviewers])
697 697 reviewers_changes['removed'] = sorted(
698 698 [get_user_or_error(n).username for n in removed_reviewers])
699 699 Session().commit()
700 700
701 701 if str2bool(Optional.extract(close_pull_request)):
702 702 PullRequestModel().close_pull_request_with_comment(
703 703 pull_request, apiuser, repo)
704 704 Session().commit()
705 705
706 706 data = {
707 707 'msg': 'Updated pull request `{}`'.format(
708 708 pull_request.pull_request_id),
709 709 'pull_request': pull_request.get_api_data(),
710 710 'updated_commits': commit_changes,
711 711 'updated_reviewers': reviewers_changes
712 712 }
713 713
714 714 return data
@@ -1,1960 +1,1960 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2017 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 time
23 23
24 24 import rhodecode
25 25 from rhodecode.api import (
26 26 jsonrpc_method, JSONRPCError, JSONRPCForbidden, JSONRPCValidationError)
27 27 from rhodecode.api.utils import (
28 28 has_superadmin_permission, Optional, OAttr, get_repo_or_error,
29 29 get_user_group_or_error, get_user_or_error, validate_repo_permissions,
30 30 get_perm_or_error, parse_args, get_origin, build_commit_data,
31 31 validate_set_owner_permissions)
32 32 from rhodecode.lib.auth import HasPermissionAnyApi, HasUserGroupPermissionAnyApi
33 33 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError
34 34 from rhodecode.lib.utils2 import str2bool, time_to_datetime
35 35 from rhodecode.lib.ext_json import json
36 36 from rhodecode.model.changeset_status import ChangesetStatusModel
37 from rhodecode.model.comment import ChangesetCommentsModel
37 from rhodecode.model.comment import CommentsModel
38 38 from rhodecode.model.db import (
39 39 Session, ChangesetStatus, RepositoryField, Repository, RepoGroup)
40 40 from rhodecode.model.repo import RepoModel
41 41 from rhodecode.model.scm import ScmModel, RepoList
42 42 from rhodecode.model.settings import SettingsModel, VcsSettingsModel
43 43 from rhodecode.model import validation_schema
44 44 from rhodecode.model.validation_schema.schemas import repo_schema
45 45
46 46 log = logging.getLogger(__name__)
47 47
48 48
49 49 @jsonrpc_method()
50 50 def get_repo(request, apiuser, repoid, cache=Optional(True)):
51 51 """
52 52 Gets an existing repository by its name or repository_id.
53 53
54 54 The members section so the output returns users groups or users
55 55 associated with that repository.
56 56
57 57 This command can only be run using an |authtoken| with admin rights,
58 58 or users with at least read rights to the |repo|.
59 59
60 60 :param apiuser: This is filled automatically from the |authtoken|.
61 61 :type apiuser: AuthUser
62 62 :param repoid: The repository name or repository id.
63 63 :type repoid: str or int
64 64 :param cache: use the cached value for last changeset
65 65 :type: cache: Optional(bool)
66 66
67 67 Example output:
68 68
69 69 .. code-block:: bash
70 70
71 71 {
72 72 "error": null,
73 73 "id": <repo_id>,
74 74 "result": {
75 75 "clone_uri": null,
76 76 "created_on": "timestamp",
77 77 "description": "repo description",
78 78 "enable_downloads": false,
79 79 "enable_locking": false,
80 80 "enable_statistics": false,
81 81 "followers": [
82 82 {
83 83 "active": true,
84 84 "admin": false,
85 85 "api_key": "****************************************",
86 86 "api_keys": [
87 87 "****************************************"
88 88 ],
89 89 "email": "user@example.com",
90 90 "emails": [
91 91 "user@example.com"
92 92 ],
93 93 "extern_name": "rhodecode",
94 94 "extern_type": "rhodecode",
95 95 "firstname": "username",
96 96 "ip_addresses": [],
97 97 "language": null,
98 98 "last_login": "2015-09-16T17:16:35.854",
99 99 "lastname": "surname",
100 100 "user_id": <user_id>,
101 101 "username": "name"
102 102 }
103 103 ],
104 104 "fork_of": "parent-repo",
105 105 "landing_rev": [
106 106 "rev",
107 107 "tip"
108 108 ],
109 109 "last_changeset": {
110 110 "author": "User <user@example.com>",
111 111 "branch": "default",
112 112 "date": "timestamp",
113 113 "message": "last commit message",
114 114 "parents": [
115 115 {
116 116 "raw_id": "commit-id"
117 117 }
118 118 ],
119 119 "raw_id": "commit-id",
120 120 "revision": <revision number>,
121 121 "short_id": "short id"
122 122 },
123 123 "lock_reason": null,
124 124 "locked_by": null,
125 125 "locked_date": null,
126 126 "members": [
127 127 {
128 128 "name": "super-admin-name",
129 129 "origin": "super-admin",
130 130 "permission": "repository.admin",
131 131 "type": "user"
132 132 },
133 133 {
134 134 "name": "owner-name",
135 135 "origin": "owner",
136 136 "permission": "repository.admin",
137 137 "type": "user"
138 138 },
139 139 {
140 140 "name": "user-group-name",
141 141 "origin": "permission",
142 142 "permission": "repository.write",
143 143 "type": "user_group"
144 144 }
145 145 ],
146 146 "owner": "owner-name",
147 147 "permissions": [
148 148 {
149 149 "name": "super-admin-name",
150 150 "origin": "super-admin",
151 151 "permission": "repository.admin",
152 152 "type": "user"
153 153 },
154 154 {
155 155 "name": "owner-name",
156 156 "origin": "owner",
157 157 "permission": "repository.admin",
158 158 "type": "user"
159 159 },
160 160 {
161 161 "name": "user-group-name",
162 162 "origin": "permission",
163 163 "permission": "repository.write",
164 164 "type": "user_group"
165 165 }
166 166 ],
167 167 "private": true,
168 168 "repo_id": 676,
169 169 "repo_name": "user-group/repo-name",
170 170 "repo_type": "hg"
171 171 }
172 172 }
173 173 """
174 174
175 175 repo = get_repo_or_error(repoid)
176 176 cache = Optional.extract(cache)
177 177
178 178 include_secrets = False
179 179 if has_superadmin_permission(apiuser):
180 180 include_secrets = True
181 181 else:
182 182 # check if we have at least read permission for this repo !
183 183 _perms = (
184 184 'repository.admin', 'repository.write', 'repository.read',)
185 185 validate_repo_permissions(apiuser, repoid, repo, _perms)
186 186
187 187 permissions = []
188 188 for _user in repo.permissions():
189 189 user_data = {
190 190 'name': _user.username,
191 191 'permission': _user.permission,
192 192 'origin': get_origin(_user),
193 193 'type': "user",
194 194 }
195 195 permissions.append(user_data)
196 196
197 197 for _user_group in repo.permission_user_groups():
198 198 user_group_data = {
199 199 'name': _user_group.users_group_name,
200 200 'permission': _user_group.permission,
201 201 'origin': get_origin(_user_group),
202 202 'type': "user_group",
203 203 }
204 204 permissions.append(user_group_data)
205 205
206 206 following_users = [
207 207 user.user.get_api_data(include_secrets=include_secrets)
208 208 for user in repo.followers]
209 209
210 210 if not cache:
211 211 repo.update_commit_cache()
212 212 data = repo.get_api_data(include_secrets=include_secrets)
213 213 data['members'] = permissions # TODO: this should be deprecated soon
214 214 data['permissions'] = permissions
215 215 data['followers'] = following_users
216 216 return data
217 217
218 218
219 219 @jsonrpc_method()
220 220 def get_repos(request, apiuser, root=Optional(None), traverse=Optional(True)):
221 221 """
222 222 Lists all existing repositories.
223 223
224 224 This command can only be run using an |authtoken| with admin rights,
225 225 or users with at least read rights to |repos|.
226 226
227 227 :param apiuser: This is filled automatically from the |authtoken|.
228 228 :type apiuser: AuthUser
229 229 :param root: specify root repository group to fetch repositories.
230 230 filters the returned repositories to be members of given root group.
231 231 :type root: Optional(None)
232 232 :param traverse: traverse given root into subrepositories. With this flag
233 233 set to False, it will only return top-level repositories from `root`.
234 234 if root is empty it will return just top-level repositories.
235 235 :type traverse: Optional(True)
236 236
237 237
238 238 Example output:
239 239
240 240 .. code-block:: bash
241 241
242 242 id : <id_given_in_input>
243 243 result: [
244 244 {
245 245 "repo_id" : "<repo_id>",
246 246 "repo_name" : "<reponame>"
247 247 "repo_type" : "<repo_type>",
248 248 "clone_uri" : "<clone_uri>",
249 249 "private": : "<bool>",
250 250 "created_on" : "<datetimecreated>",
251 251 "description" : "<description>",
252 252 "landing_rev": "<landing_rev>",
253 253 "owner": "<repo_owner>",
254 254 "fork_of": "<name_of_fork_parent>",
255 255 "enable_downloads": "<bool>",
256 256 "enable_locking": "<bool>",
257 257 "enable_statistics": "<bool>",
258 258 },
259 259 ...
260 260 ]
261 261 error: null
262 262 """
263 263
264 264 include_secrets = has_superadmin_permission(apiuser)
265 265 _perms = ('repository.read', 'repository.write', 'repository.admin',)
266 266 extras = {'user': apiuser}
267 267
268 268 root = Optional.extract(root)
269 269 traverse = Optional.extract(traverse, binary=True)
270 270
271 271 if root:
272 272 # verify parent existance, if it's empty return an error
273 273 parent = RepoGroup.get_by_group_name(root)
274 274 if not parent:
275 275 raise JSONRPCError(
276 276 'Root repository group `{}` does not exist'.format(root))
277 277
278 278 if traverse:
279 279 repos = RepoModel().get_repos_for_root(root=root, traverse=traverse)
280 280 else:
281 281 repos = RepoModel().get_repos_for_root(root=parent)
282 282 else:
283 283 if traverse:
284 284 repos = RepoModel().get_all()
285 285 else:
286 286 # return just top-level
287 287 repos = RepoModel().get_repos_for_root(root=None)
288 288
289 289 repo_list = RepoList(repos, perm_set=_perms, extra_kwargs=extras)
290 290 return [repo.get_api_data(include_secrets=include_secrets)
291 291 for repo in repo_list]
292 292
293 293
294 294 @jsonrpc_method()
295 295 def get_repo_changeset(request, apiuser, repoid, revision,
296 296 details=Optional('basic')):
297 297 """
298 298 Returns information about a changeset.
299 299
300 300 Additionally parameters define the amount of details returned by
301 301 this function.
302 302
303 303 This command can only be run using an |authtoken| with admin rights,
304 304 or users with at least read rights to the |repo|.
305 305
306 306 :param apiuser: This is filled automatically from the |authtoken|.
307 307 :type apiuser: AuthUser
308 308 :param repoid: The repository name or repository id
309 309 :type repoid: str or int
310 310 :param revision: revision for which listing should be done
311 311 :type revision: str
312 312 :param details: details can be 'basic|extended|full' full gives diff
313 313 info details like the diff itself, and number of changed files etc.
314 314 :type details: Optional(str)
315 315
316 316 """
317 317 repo = get_repo_or_error(repoid)
318 318 if not has_superadmin_permission(apiuser):
319 319 _perms = (
320 320 'repository.admin', 'repository.write', 'repository.read',)
321 321 validate_repo_permissions(apiuser, repoid, repo, _perms)
322 322
323 323 changes_details = Optional.extract(details)
324 324 _changes_details_types = ['basic', 'extended', 'full']
325 325 if changes_details not in _changes_details_types:
326 326 raise JSONRPCError(
327 327 'ret_type must be one of %s' % (
328 328 ','.join(_changes_details_types)))
329 329
330 330 pre_load = ['author', 'branch', 'date', 'message', 'parents',
331 331 'status', '_commit', '_file_paths']
332 332
333 333 try:
334 334 cs = repo.get_commit(commit_id=revision, pre_load=pre_load)
335 335 except TypeError as e:
336 336 raise JSONRPCError(e.message)
337 337 _cs_json = cs.__json__()
338 338 _cs_json['diff'] = build_commit_data(cs, changes_details)
339 339 if changes_details == 'full':
340 340 _cs_json['refs'] = {
341 341 'branches': [cs.branch],
342 342 'bookmarks': getattr(cs, 'bookmarks', []),
343 343 'tags': cs.tags
344 344 }
345 345 return _cs_json
346 346
347 347
348 348 @jsonrpc_method()
349 349 def get_repo_changesets(request, apiuser, repoid, start_rev, limit,
350 350 details=Optional('basic')):
351 351 """
352 352 Returns a set of commits limited by the number starting
353 353 from the `start_rev` option.
354 354
355 355 Additional parameters define the amount of details returned by this
356 356 function.
357 357
358 358 This command can only be run using an |authtoken| with admin rights,
359 359 or users with at least read rights to |repos|.
360 360
361 361 :param apiuser: This is filled automatically from the |authtoken|.
362 362 :type apiuser: AuthUser
363 363 :param repoid: The repository name or repository ID.
364 364 :type repoid: str or int
365 365 :param start_rev: The starting revision from where to get changesets.
366 366 :type start_rev: str
367 367 :param limit: Limit the number of commits to this amount
368 368 :type limit: str or int
369 369 :param details: Set the level of detail returned. Valid option are:
370 370 ``basic``, ``extended`` and ``full``.
371 371 :type details: Optional(str)
372 372
373 373 .. note::
374 374
375 375 Setting the parameter `details` to the value ``full`` is extensive
376 376 and returns details like the diff itself, and the number
377 377 of changed files.
378 378
379 379 """
380 380 repo = get_repo_or_error(repoid)
381 381 if not has_superadmin_permission(apiuser):
382 382 _perms = (
383 383 'repository.admin', 'repository.write', 'repository.read',)
384 384 validate_repo_permissions(apiuser, repoid, repo, _perms)
385 385
386 386 changes_details = Optional.extract(details)
387 387 _changes_details_types = ['basic', 'extended', 'full']
388 388 if changes_details not in _changes_details_types:
389 389 raise JSONRPCError(
390 390 'ret_type must be one of %s' % (
391 391 ','.join(_changes_details_types)))
392 392
393 393 limit = int(limit)
394 394 pre_load = ['author', 'branch', 'date', 'message', 'parents',
395 395 'status', '_commit', '_file_paths']
396 396
397 397 vcs_repo = repo.scm_instance()
398 398 # SVN needs a special case to distinguish its index and commit id
399 399 if vcs_repo and vcs_repo.alias == 'svn' and (start_rev == '0'):
400 400 start_rev = vcs_repo.commit_ids[0]
401 401
402 402 try:
403 403 commits = vcs_repo.get_commits(
404 404 start_id=start_rev, pre_load=pre_load)
405 405 except TypeError as e:
406 406 raise JSONRPCError(e.message)
407 407 except Exception:
408 408 log.exception('Fetching of commits failed')
409 409 raise JSONRPCError('Error occurred during commit fetching')
410 410
411 411 ret = []
412 412 for cnt, commit in enumerate(commits):
413 413 if cnt >= limit != -1:
414 414 break
415 415 _cs_json = commit.__json__()
416 416 _cs_json['diff'] = build_commit_data(commit, changes_details)
417 417 if changes_details == 'full':
418 418 _cs_json['refs'] = {
419 419 'branches': [commit.branch],
420 420 'bookmarks': getattr(commit, 'bookmarks', []),
421 421 'tags': commit.tags
422 422 }
423 423 ret.append(_cs_json)
424 424 return ret
425 425
426 426
427 427 @jsonrpc_method()
428 428 def get_repo_nodes(request, apiuser, repoid, revision, root_path,
429 429 ret_type=Optional('all'), details=Optional('basic'),
430 430 max_file_bytes=Optional(None)):
431 431 """
432 432 Returns a list of nodes and children in a flat list for a given
433 433 path at given revision.
434 434
435 435 It's possible to specify ret_type to show only `files` or `dirs`.
436 436
437 437 This command can only be run using an |authtoken| with admin rights,
438 438 or users with at least read rights to |repos|.
439 439
440 440 :param apiuser: This is filled automatically from the |authtoken|.
441 441 :type apiuser: AuthUser
442 442 :param repoid: The repository name or repository ID.
443 443 :type repoid: str or int
444 444 :param revision: The revision for which listing should be done.
445 445 :type revision: str
446 446 :param root_path: The path from which to start displaying.
447 447 :type root_path: str
448 448 :param ret_type: Set the return type. Valid options are
449 449 ``all`` (default), ``files`` and ``dirs``.
450 450 :type ret_type: Optional(str)
451 451 :param details: Returns extended information about nodes, such as
452 452 md5, binary, and or content. The valid options are ``basic`` and
453 453 ``full``.
454 454 :type details: Optional(str)
455 455 :param max_file_bytes: Only return file content under this file size bytes
456 456 :type details: Optional(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 {
465 465 "name" : "<name>"
466 466 "type" : "<type>",
467 467 "binary": "<true|false>" (only in extended mode)
468 468 "md5" : "<md5 of file content>" (only in extended mode)
469 469 },
470 470 ...
471 471 ]
472 472 error: null
473 473 """
474 474
475 475 repo = get_repo_or_error(repoid)
476 476 if not has_superadmin_permission(apiuser):
477 477 _perms = (
478 478 'repository.admin', 'repository.write', 'repository.read',)
479 479 validate_repo_permissions(apiuser, repoid, repo, _perms)
480 480
481 481 ret_type = Optional.extract(ret_type)
482 482 details = Optional.extract(details)
483 483 _extended_types = ['basic', 'full']
484 484 if details not in _extended_types:
485 485 raise JSONRPCError(
486 486 'ret_type must be one of %s' % (','.join(_extended_types)))
487 487 extended_info = False
488 488 content = False
489 489 if details == 'basic':
490 490 extended_info = True
491 491
492 492 if details == 'full':
493 493 extended_info = content = True
494 494
495 495 _map = {}
496 496 try:
497 497 # check if repo is not empty by any chance, skip quicker if it is.
498 498 _scm = repo.scm_instance()
499 499 if _scm.is_empty():
500 500 return []
501 501
502 502 _d, _f = ScmModel().get_nodes(
503 503 repo, revision, root_path, flat=False,
504 504 extended_info=extended_info, content=content,
505 505 max_file_bytes=max_file_bytes)
506 506 _map = {
507 507 'all': _d + _f,
508 508 'files': _f,
509 509 'dirs': _d,
510 510 }
511 511 return _map[ret_type]
512 512 except KeyError:
513 513 raise JSONRPCError(
514 514 'ret_type must be one of %s' % (','.join(sorted(_map.keys()))))
515 515 except Exception:
516 516 log.exception("Exception occurred while trying to get repo nodes")
517 517 raise JSONRPCError(
518 518 'failed to get repo: `%s` nodes' % repo.repo_name
519 519 )
520 520
521 521
522 522 @jsonrpc_method()
523 523 def get_repo_refs(request, apiuser, repoid):
524 524 """
525 525 Returns a dictionary of current references. It returns
526 526 bookmarks, branches, closed_branches, and tags for given repository
527 527
528 528 It's possible to specify ret_type to show only `files` or `dirs`.
529 529
530 530 This command can only be run using an |authtoken| with admin rights,
531 531 or users with at least read rights to |repos|.
532 532
533 533 :param apiuser: This is filled automatically from the |authtoken|.
534 534 :type apiuser: AuthUser
535 535 :param repoid: The repository name or repository ID.
536 536 :type repoid: str or int
537 537
538 538 Example output:
539 539
540 540 .. code-block:: bash
541 541
542 542 id : <id_given_in_input>
543 543 "result": {
544 544 "bookmarks": {
545 545 "dev": "5611d30200f4040ba2ab4f3d64e5b06408a02188",
546 546 "master": "367f590445081d8ec8c2ea0456e73ae1f1c3d6cf"
547 547 },
548 548 "branches": {
549 549 "default": "5611d30200f4040ba2ab4f3d64e5b06408a02188",
550 550 "stable": "367f590445081d8ec8c2ea0456e73ae1f1c3d6cf"
551 551 },
552 552 "branches_closed": {},
553 553 "tags": {
554 554 "tip": "5611d30200f4040ba2ab4f3d64e5b06408a02188",
555 555 "v4.4.0": "1232313f9e6adac5ce5399c2a891dc1e72b79022",
556 556 "v4.4.1": "cbb9f1d329ae5768379cdec55a62ebdd546c4e27",
557 557 "v4.4.2": "24ffe44a27fcd1c5b6936144e176b9f6dd2f3a17",
558 558 }
559 559 }
560 560 error: null
561 561 """
562 562
563 563 repo = get_repo_or_error(repoid)
564 564 if not has_superadmin_permission(apiuser):
565 565 _perms = ('repository.admin', 'repository.write', 'repository.read',)
566 566 validate_repo_permissions(apiuser, repoid, repo, _perms)
567 567
568 568 try:
569 569 # check if repo is not empty by any chance, skip quicker if it is.
570 570 vcs_instance = repo.scm_instance()
571 571 refs = vcs_instance.refs()
572 572 return refs
573 573 except Exception:
574 574 log.exception("Exception occurred while trying to get repo refs")
575 575 raise JSONRPCError(
576 576 'failed to get repo: `%s` references' % repo.repo_name
577 577 )
578 578
579 579
580 580 @jsonrpc_method()
581 581 def create_repo(
582 582 request, apiuser, repo_name, repo_type,
583 583 owner=Optional(OAttr('apiuser')),
584 584 description=Optional(''),
585 585 private=Optional(False),
586 586 clone_uri=Optional(None),
587 587 landing_rev=Optional('rev:tip'),
588 588 enable_statistics=Optional(False),
589 589 enable_locking=Optional(False),
590 590 enable_downloads=Optional(False),
591 591 copy_permissions=Optional(False)):
592 592 """
593 593 Creates a repository.
594 594
595 595 * If the repository name contains "/", repository will be created inside
596 596 a repository group or nested repository groups
597 597
598 598 For example "foo/bar/repo1" will create |repo| called "repo1" inside
599 599 group "foo/bar". You have to have permissions to access and write to
600 600 the last repository group ("bar" in this example)
601 601
602 602 This command can only be run using an |authtoken| with at least
603 603 permissions to create repositories, or write permissions to
604 604 parent repository groups.
605 605
606 606 :param apiuser: This is filled automatically from the |authtoken|.
607 607 :type apiuser: AuthUser
608 608 :param repo_name: Set the repository name.
609 609 :type repo_name: str
610 610 :param repo_type: Set the repository type; 'hg','git', or 'svn'.
611 611 :type repo_type: str
612 612 :param owner: user_id or username
613 613 :type owner: Optional(str)
614 614 :param description: Set the repository description.
615 615 :type description: Optional(str)
616 616 :param private: set repository as private
617 617 :type private: bool
618 618 :param clone_uri: set clone_uri
619 619 :type clone_uri: str
620 620 :param landing_rev: <rev_type>:<rev>
621 621 :type landing_rev: str
622 622 :param enable_locking:
623 623 :type enable_locking: bool
624 624 :param enable_downloads:
625 625 :type enable_downloads: bool
626 626 :param enable_statistics:
627 627 :type enable_statistics: bool
628 628 :param copy_permissions: Copy permission from group in which the
629 629 repository is being created.
630 630 :type copy_permissions: bool
631 631
632 632
633 633 Example output:
634 634
635 635 .. code-block:: bash
636 636
637 637 id : <id_given_in_input>
638 638 result: {
639 639 "msg": "Created new repository `<reponame>`",
640 640 "success": true,
641 641 "task": "<celery task id or None if done sync>"
642 642 }
643 643 error: null
644 644
645 645
646 646 Example error output:
647 647
648 648 .. code-block:: bash
649 649
650 650 id : <id_given_in_input>
651 651 result : null
652 652 error : {
653 653 'failed to create repository `<repo_name>`'
654 654 }
655 655
656 656 """
657 657
658 658 owner = validate_set_owner_permissions(apiuser, owner)
659 659
660 660 description = Optional.extract(description)
661 661 copy_permissions = Optional.extract(copy_permissions)
662 662 clone_uri = Optional.extract(clone_uri)
663 663 landing_commit_ref = Optional.extract(landing_rev)
664 664
665 665 defs = SettingsModel().get_default_repo_settings(strip_prefix=True)
666 666 if isinstance(private, Optional):
667 667 private = defs.get('repo_private') or Optional.extract(private)
668 668 if isinstance(repo_type, Optional):
669 669 repo_type = defs.get('repo_type')
670 670 if isinstance(enable_statistics, Optional):
671 671 enable_statistics = defs.get('repo_enable_statistics')
672 672 if isinstance(enable_locking, Optional):
673 673 enable_locking = defs.get('repo_enable_locking')
674 674 if isinstance(enable_downloads, Optional):
675 675 enable_downloads = defs.get('repo_enable_downloads')
676 676
677 677 schema = repo_schema.RepoSchema().bind(
678 678 repo_type_options=rhodecode.BACKENDS.keys(),
679 679 # user caller
680 680 user=apiuser)
681 681
682 682 try:
683 683 schema_data = schema.deserialize(dict(
684 684 repo_name=repo_name,
685 685 repo_type=repo_type,
686 686 repo_owner=owner.username,
687 687 repo_description=description,
688 688 repo_landing_commit_ref=landing_commit_ref,
689 689 repo_clone_uri=clone_uri,
690 690 repo_private=private,
691 691 repo_copy_permissions=copy_permissions,
692 692 repo_enable_statistics=enable_statistics,
693 693 repo_enable_downloads=enable_downloads,
694 694 repo_enable_locking=enable_locking))
695 695 except validation_schema.Invalid as err:
696 696 raise JSONRPCValidationError(colander_exc=err)
697 697
698 698 try:
699 699 data = {
700 700 'owner': owner,
701 701 'repo_name': schema_data['repo_group']['repo_name_without_group'],
702 702 'repo_name_full': schema_data['repo_name'],
703 703 'repo_group': schema_data['repo_group']['repo_group_id'],
704 704 'repo_type': schema_data['repo_type'],
705 705 'repo_description': schema_data['repo_description'],
706 706 'repo_private': schema_data['repo_private'],
707 707 'clone_uri': schema_data['repo_clone_uri'],
708 708 'repo_landing_rev': schema_data['repo_landing_commit_ref'],
709 709 'enable_statistics': schema_data['repo_enable_statistics'],
710 710 'enable_locking': schema_data['repo_enable_locking'],
711 711 'enable_downloads': schema_data['repo_enable_downloads'],
712 712 'repo_copy_permissions': schema_data['repo_copy_permissions'],
713 713 }
714 714
715 715 task = RepoModel().create(form_data=data, cur_user=owner)
716 716 from celery.result import BaseAsyncResult
717 717 task_id = None
718 718 if isinstance(task, BaseAsyncResult):
719 719 task_id = task.task_id
720 720 # no commit, it's done in RepoModel, or async via celery
721 721 return {
722 722 'msg': "Created new repository `%s`" % (schema_data['repo_name'],),
723 723 'success': True, # cannot return the repo data here since fork
724 724 # can be done async
725 725 'task': task_id
726 726 }
727 727 except Exception:
728 728 log.exception(
729 729 u"Exception while trying to create the repository %s",
730 730 schema_data['repo_name'])
731 731 raise JSONRPCError(
732 732 'failed to create repository `%s`' % (schema_data['repo_name'],))
733 733
734 734
735 735 @jsonrpc_method()
736 736 def add_field_to_repo(request, apiuser, repoid, key, label=Optional(''),
737 737 description=Optional('')):
738 738 """
739 739 Adds an extra field to a repository.
740 740
741 741 This command can only be run using an |authtoken| with at least
742 742 write permissions to the |repo|.
743 743
744 744 :param apiuser: This is filled automatically from the |authtoken|.
745 745 :type apiuser: AuthUser
746 746 :param repoid: Set the repository name or repository id.
747 747 :type repoid: str or int
748 748 :param key: Create a unique field key for this repository.
749 749 :type key: str
750 750 :param label:
751 751 :type label: Optional(str)
752 752 :param description:
753 753 :type description: Optional(str)
754 754 """
755 755 repo = get_repo_or_error(repoid)
756 756 if not has_superadmin_permission(apiuser):
757 757 _perms = ('repository.admin',)
758 758 validate_repo_permissions(apiuser, repoid, repo, _perms)
759 759
760 760 label = Optional.extract(label) or key
761 761 description = Optional.extract(description)
762 762
763 763 field = RepositoryField.get_by_key_name(key, repo)
764 764 if field:
765 765 raise JSONRPCError('Field with key '
766 766 '`%s` exists for repo `%s`' % (key, repoid))
767 767
768 768 try:
769 769 RepoModel().add_repo_field(repo, key, field_label=label,
770 770 field_desc=description)
771 771 Session().commit()
772 772 return {
773 773 'msg': "Added new repository field `%s`" % (key,),
774 774 'success': True,
775 775 }
776 776 except Exception:
777 777 log.exception("Exception occurred while trying to add field to repo")
778 778 raise JSONRPCError(
779 779 'failed to create new field for repository `%s`' % (repoid,))
780 780
781 781
782 782 @jsonrpc_method()
783 783 def remove_field_from_repo(request, apiuser, repoid, key):
784 784 """
785 785 Removes an extra field from a repository.
786 786
787 787 This command can only be run using an |authtoken| with at least
788 788 write permissions to the |repo|.
789 789
790 790 :param apiuser: This is filled automatically from the |authtoken|.
791 791 :type apiuser: AuthUser
792 792 :param repoid: Set the repository name or repository ID.
793 793 :type repoid: str or int
794 794 :param key: Set the unique field key for this repository.
795 795 :type key: str
796 796 """
797 797
798 798 repo = get_repo_or_error(repoid)
799 799 if not has_superadmin_permission(apiuser):
800 800 _perms = ('repository.admin',)
801 801 validate_repo_permissions(apiuser, repoid, repo, _perms)
802 802
803 803 field = RepositoryField.get_by_key_name(key, repo)
804 804 if not field:
805 805 raise JSONRPCError('Field with key `%s` does not '
806 806 'exists for repo `%s`' % (key, repoid))
807 807
808 808 try:
809 809 RepoModel().delete_repo_field(repo, field_key=key)
810 810 Session().commit()
811 811 return {
812 812 'msg': "Deleted repository field `%s`" % (key,),
813 813 'success': True,
814 814 }
815 815 except Exception:
816 816 log.exception(
817 817 "Exception occurred while trying to delete field from repo")
818 818 raise JSONRPCError(
819 819 'failed to delete field for repository `%s`' % (repoid,))
820 820
821 821
822 822 @jsonrpc_method()
823 823 def update_repo(
824 824 request, apiuser, repoid, repo_name=Optional(None),
825 825 owner=Optional(OAttr('apiuser')), description=Optional(''),
826 826 private=Optional(False), clone_uri=Optional(None),
827 827 landing_rev=Optional('rev:tip'), fork_of=Optional(None),
828 828 enable_statistics=Optional(False),
829 829 enable_locking=Optional(False),
830 830 enable_downloads=Optional(False), fields=Optional('')):
831 831 """
832 832 Updates a repository with the given information.
833 833
834 834 This command can only be run using an |authtoken| with at least
835 835 admin permissions to the |repo|.
836 836
837 837 * If the repository name contains "/", repository will be updated
838 838 accordingly with a repository group or nested repository groups
839 839
840 840 For example repoid=repo-test name="foo/bar/repo-test" will update |repo|
841 841 called "repo-test" and place it inside group "foo/bar".
842 842 You have to have permissions to access and write to the last repository
843 843 group ("bar" in this example)
844 844
845 845 :param apiuser: This is filled automatically from the |authtoken|.
846 846 :type apiuser: AuthUser
847 847 :param repoid: repository name or repository ID.
848 848 :type repoid: str or int
849 849 :param repo_name: Update the |repo| name, including the
850 850 repository group it's in.
851 851 :type repo_name: str
852 852 :param owner: Set the |repo| owner.
853 853 :type owner: str
854 854 :param fork_of: Set the |repo| as fork of another |repo|.
855 855 :type fork_of: str
856 856 :param description: Update the |repo| description.
857 857 :type description: str
858 858 :param private: Set the |repo| as private. (True | False)
859 859 :type private: bool
860 860 :param clone_uri: Update the |repo| clone URI.
861 861 :type clone_uri: str
862 862 :param landing_rev: Set the |repo| landing revision. Default is ``rev:tip``.
863 863 :type landing_rev: str
864 864 :param enable_statistics: Enable statistics on the |repo|, (True | False).
865 865 :type enable_statistics: bool
866 866 :param enable_locking: Enable |repo| locking.
867 867 :type enable_locking: bool
868 868 :param enable_downloads: Enable downloads from the |repo|, (True | False).
869 869 :type enable_downloads: bool
870 870 :param fields: Add extra fields to the |repo|. Use the following
871 871 example format: ``field_key=field_val,field_key2=fieldval2``.
872 872 Escape ', ' with \,
873 873 :type fields: str
874 874 """
875 875
876 876 repo = get_repo_or_error(repoid)
877 877
878 878 include_secrets = False
879 879 if not has_superadmin_permission(apiuser):
880 880 validate_repo_permissions(apiuser, repoid, repo, ('repository.admin',))
881 881 else:
882 882 include_secrets = True
883 883
884 884 updates = dict(
885 885 repo_name=repo_name
886 886 if not isinstance(repo_name, Optional) else repo.repo_name,
887 887
888 888 fork_id=fork_of
889 889 if not isinstance(fork_of, Optional) else repo.fork.repo_name if repo.fork else None,
890 890
891 891 user=owner
892 892 if not isinstance(owner, Optional) else repo.user.username,
893 893
894 894 repo_description=description
895 895 if not isinstance(description, Optional) else repo.description,
896 896
897 897 repo_private=private
898 898 if not isinstance(private, Optional) else repo.private,
899 899
900 900 clone_uri=clone_uri
901 901 if not isinstance(clone_uri, Optional) else repo.clone_uri,
902 902
903 903 repo_landing_rev=landing_rev
904 904 if not isinstance(landing_rev, Optional) else repo._landing_revision,
905 905
906 906 repo_enable_statistics=enable_statistics
907 907 if not isinstance(enable_statistics, Optional) else repo.enable_statistics,
908 908
909 909 repo_enable_locking=enable_locking
910 910 if not isinstance(enable_locking, Optional) else repo.enable_locking,
911 911
912 912 repo_enable_downloads=enable_downloads
913 913 if not isinstance(enable_downloads, Optional) else repo.enable_downloads)
914 914
915 915 ref_choices, _labels = ScmModel().get_repo_landing_revs(repo=repo)
916 916
917 917 schema = repo_schema.RepoSchema().bind(
918 918 repo_type_options=rhodecode.BACKENDS.keys(),
919 919 repo_ref_options=ref_choices,
920 920 # user caller
921 921 user=apiuser,
922 922 old_values=repo.get_api_data())
923 923 try:
924 924 schema_data = schema.deserialize(dict(
925 925 # we save old value, users cannot change type
926 926 repo_type=repo.repo_type,
927 927
928 928 repo_name=updates['repo_name'],
929 929 repo_owner=updates['user'],
930 930 repo_description=updates['repo_description'],
931 931 repo_clone_uri=updates['clone_uri'],
932 932 repo_fork_of=updates['fork_id'],
933 933 repo_private=updates['repo_private'],
934 934 repo_landing_commit_ref=updates['repo_landing_rev'],
935 935 repo_enable_statistics=updates['repo_enable_statistics'],
936 936 repo_enable_downloads=updates['repo_enable_downloads'],
937 937 repo_enable_locking=updates['repo_enable_locking']))
938 938 except validation_schema.Invalid as err:
939 939 raise JSONRPCValidationError(colander_exc=err)
940 940
941 941 # save validated data back into the updates dict
942 942 validated_updates = dict(
943 943 repo_name=schema_data['repo_group']['repo_name_without_group'],
944 944 repo_group=schema_data['repo_group']['repo_group_id'],
945 945
946 946 user=schema_data['repo_owner'],
947 947 repo_description=schema_data['repo_description'],
948 948 repo_private=schema_data['repo_private'],
949 949 clone_uri=schema_data['repo_clone_uri'],
950 950 repo_landing_rev=schema_data['repo_landing_commit_ref'],
951 951 repo_enable_statistics=schema_data['repo_enable_statistics'],
952 952 repo_enable_locking=schema_data['repo_enable_locking'],
953 953 repo_enable_downloads=schema_data['repo_enable_downloads'],
954 954 )
955 955
956 956 if schema_data['repo_fork_of']:
957 957 fork_repo = get_repo_or_error(schema_data['repo_fork_of'])
958 958 validated_updates['fork_id'] = fork_repo.repo_id
959 959
960 960 # extra fields
961 961 fields = parse_args(Optional.extract(fields), key_prefix='ex_')
962 962 if fields:
963 963 validated_updates.update(fields)
964 964
965 965 try:
966 966 RepoModel().update(repo, **validated_updates)
967 967 Session().commit()
968 968 return {
969 969 'msg': 'updated repo ID:%s %s' % (repo.repo_id, repo.repo_name),
970 970 'repository': repo.get_api_data(include_secrets=include_secrets)
971 971 }
972 972 except Exception:
973 973 log.exception(
974 974 u"Exception while trying to update the repository %s",
975 975 repoid)
976 976 raise JSONRPCError('failed to update repo `%s`' % repoid)
977 977
978 978
979 979 @jsonrpc_method()
980 980 def fork_repo(request, apiuser, repoid, fork_name,
981 981 owner=Optional(OAttr('apiuser')),
982 982 description=Optional(''),
983 983 private=Optional(False),
984 984 clone_uri=Optional(None),
985 985 landing_rev=Optional('rev:tip'),
986 986 copy_permissions=Optional(False)):
987 987 """
988 988 Creates a fork of the specified |repo|.
989 989
990 990 * If the fork_name contains "/", fork will be created inside
991 991 a repository group or nested repository groups
992 992
993 993 For example "foo/bar/fork-repo" will create fork called "fork-repo"
994 994 inside group "foo/bar". You have to have permissions to access and
995 995 write to the last repository group ("bar" in this example)
996 996
997 997 This command can only be run using an |authtoken| with minimum
998 998 read permissions of the forked repo, create fork permissions for an user.
999 999
1000 1000 :param apiuser: This is filled automatically from the |authtoken|.
1001 1001 :type apiuser: AuthUser
1002 1002 :param repoid: Set repository name or repository ID.
1003 1003 :type repoid: str or int
1004 1004 :param fork_name: Set the fork name, including it's repository group membership.
1005 1005 :type fork_name: str
1006 1006 :param owner: Set the fork owner.
1007 1007 :type owner: str
1008 1008 :param description: Set the fork description.
1009 1009 :type description: str
1010 1010 :param copy_permissions: Copy permissions from parent |repo|. The
1011 1011 default is False.
1012 1012 :type copy_permissions: bool
1013 1013 :param private: Make the fork private. The default is False.
1014 1014 :type private: bool
1015 1015 :param landing_rev: Set the landing revision. The default is tip.
1016 1016
1017 1017 Example output:
1018 1018
1019 1019 .. code-block:: bash
1020 1020
1021 1021 id : <id_for_response>
1022 1022 api_key : "<api_key>"
1023 1023 args: {
1024 1024 "repoid" : "<reponame or repo_id>",
1025 1025 "fork_name": "<forkname>",
1026 1026 "owner": "<username or user_id = Optional(=apiuser)>",
1027 1027 "description": "<description>",
1028 1028 "copy_permissions": "<bool>",
1029 1029 "private": "<bool>",
1030 1030 "landing_rev": "<landing_rev>"
1031 1031 }
1032 1032
1033 1033 Example error output:
1034 1034
1035 1035 .. code-block:: bash
1036 1036
1037 1037 id : <id_given_in_input>
1038 1038 result: {
1039 1039 "msg": "Created fork of `<reponame>` as `<forkname>`",
1040 1040 "success": true,
1041 1041 "task": "<celery task id or None if done sync>"
1042 1042 }
1043 1043 error: null
1044 1044
1045 1045 """
1046 1046
1047 1047 repo = get_repo_or_error(repoid)
1048 1048 repo_name = repo.repo_name
1049 1049
1050 1050 if not has_superadmin_permission(apiuser):
1051 1051 # check if we have at least read permission for
1052 1052 # this repo that we fork !
1053 1053 _perms = (
1054 1054 'repository.admin', 'repository.write', 'repository.read')
1055 1055 validate_repo_permissions(apiuser, repoid, repo, _perms)
1056 1056
1057 1057 # check if the regular user has at least fork permissions as well
1058 1058 if not HasPermissionAnyApi('hg.fork.repository')(user=apiuser):
1059 1059 raise JSONRPCForbidden()
1060 1060
1061 1061 # check if user can set owner parameter
1062 1062 owner = validate_set_owner_permissions(apiuser, owner)
1063 1063
1064 1064 description = Optional.extract(description)
1065 1065 copy_permissions = Optional.extract(copy_permissions)
1066 1066 clone_uri = Optional.extract(clone_uri)
1067 1067 landing_commit_ref = Optional.extract(landing_rev)
1068 1068 private = Optional.extract(private)
1069 1069
1070 1070 schema = repo_schema.RepoSchema().bind(
1071 1071 repo_type_options=rhodecode.BACKENDS.keys(),
1072 1072 # user caller
1073 1073 user=apiuser)
1074 1074
1075 1075 try:
1076 1076 schema_data = schema.deserialize(dict(
1077 1077 repo_name=fork_name,
1078 1078 repo_type=repo.repo_type,
1079 1079 repo_owner=owner.username,
1080 1080 repo_description=description,
1081 1081 repo_landing_commit_ref=landing_commit_ref,
1082 1082 repo_clone_uri=clone_uri,
1083 1083 repo_private=private,
1084 1084 repo_copy_permissions=copy_permissions))
1085 1085 except validation_schema.Invalid as err:
1086 1086 raise JSONRPCValidationError(colander_exc=err)
1087 1087
1088 1088 try:
1089 1089 data = {
1090 1090 'fork_parent_id': repo.repo_id,
1091 1091
1092 1092 'repo_name': schema_data['repo_group']['repo_name_without_group'],
1093 1093 'repo_name_full': schema_data['repo_name'],
1094 1094 'repo_group': schema_data['repo_group']['repo_group_id'],
1095 1095 'repo_type': schema_data['repo_type'],
1096 1096 'description': schema_data['repo_description'],
1097 1097 'private': schema_data['repo_private'],
1098 1098 'copy_permissions': schema_data['repo_copy_permissions'],
1099 1099 'landing_rev': schema_data['repo_landing_commit_ref'],
1100 1100 }
1101 1101
1102 1102 task = RepoModel().create_fork(data, cur_user=owner)
1103 1103 # no commit, it's done in RepoModel, or async via celery
1104 1104 from celery.result import BaseAsyncResult
1105 1105 task_id = None
1106 1106 if isinstance(task, BaseAsyncResult):
1107 1107 task_id = task.task_id
1108 1108 return {
1109 1109 'msg': 'Created fork of `%s` as `%s`' % (
1110 1110 repo.repo_name, schema_data['repo_name']),
1111 1111 'success': True, # cannot return the repo data here since fork
1112 1112 # can be done async
1113 1113 'task': task_id
1114 1114 }
1115 1115 except Exception:
1116 1116 log.exception(
1117 1117 u"Exception while trying to create fork %s",
1118 1118 schema_data['repo_name'])
1119 1119 raise JSONRPCError(
1120 1120 'failed to fork repository `%s` as `%s`' % (
1121 1121 repo_name, schema_data['repo_name']))
1122 1122
1123 1123
1124 1124 @jsonrpc_method()
1125 1125 def delete_repo(request, apiuser, repoid, forks=Optional('')):
1126 1126 """
1127 1127 Deletes a repository.
1128 1128
1129 1129 * When the `forks` parameter is set it's possible to detach or delete
1130 1130 forks of deleted repository.
1131 1131
1132 1132 This command can only be run using an |authtoken| with admin
1133 1133 permissions on the |repo|.
1134 1134
1135 1135 :param apiuser: This is filled automatically from the |authtoken|.
1136 1136 :type apiuser: AuthUser
1137 1137 :param repoid: Set the repository name or repository ID.
1138 1138 :type repoid: str or int
1139 1139 :param forks: Set to `detach` or `delete` forks from the |repo|.
1140 1140 :type forks: Optional(str)
1141 1141
1142 1142 Example error output:
1143 1143
1144 1144 .. code-block:: bash
1145 1145
1146 1146 id : <id_given_in_input>
1147 1147 result: {
1148 1148 "msg": "Deleted repository `<reponame>`",
1149 1149 "success": true
1150 1150 }
1151 1151 error: null
1152 1152 """
1153 1153
1154 1154 repo = get_repo_or_error(repoid)
1155 1155 if not has_superadmin_permission(apiuser):
1156 1156 _perms = ('repository.admin',)
1157 1157 validate_repo_permissions(apiuser, repoid, repo, _perms)
1158 1158
1159 1159 try:
1160 1160 handle_forks = Optional.extract(forks)
1161 1161 _forks_msg = ''
1162 1162 _forks = [f for f in repo.forks]
1163 1163 if handle_forks == 'detach':
1164 1164 _forks_msg = ' ' + 'Detached %s forks' % len(_forks)
1165 1165 elif handle_forks == 'delete':
1166 1166 _forks_msg = ' ' + 'Deleted %s forks' % len(_forks)
1167 1167 elif _forks:
1168 1168 raise JSONRPCError(
1169 1169 'Cannot delete `%s` it still contains attached forks' %
1170 1170 (repo.repo_name,)
1171 1171 )
1172 1172
1173 1173 RepoModel().delete(repo, forks=forks)
1174 1174 Session().commit()
1175 1175 return {
1176 1176 'msg': 'Deleted repository `%s`%s' % (
1177 1177 repo.repo_name, _forks_msg),
1178 1178 'success': True
1179 1179 }
1180 1180 except Exception:
1181 1181 log.exception("Exception occurred while trying to delete repo")
1182 1182 raise JSONRPCError(
1183 1183 'failed to delete repository `%s`' % (repo.repo_name,)
1184 1184 )
1185 1185
1186 1186
1187 1187 #TODO: marcink, change name ?
1188 1188 @jsonrpc_method()
1189 1189 def invalidate_cache(request, apiuser, repoid, delete_keys=Optional(False)):
1190 1190 """
1191 1191 Invalidates the cache for the specified repository.
1192 1192
1193 1193 This command can only be run using an |authtoken| with admin rights to
1194 1194 the specified repository.
1195 1195
1196 1196 This command takes the following options:
1197 1197
1198 1198 :param apiuser: This is filled automatically from |authtoken|.
1199 1199 :type apiuser: AuthUser
1200 1200 :param repoid: Sets the repository name or repository ID.
1201 1201 :type repoid: str or int
1202 1202 :param delete_keys: This deletes the invalidated keys instead of
1203 1203 just flagging them.
1204 1204 :type delete_keys: Optional(``True`` | ``False``)
1205 1205
1206 1206 Example output:
1207 1207
1208 1208 .. code-block:: bash
1209 1209
1210 1210 id : <id_given_in_input>
1211 1211 result : {
1212 1212 'msg': Cache for repository `<repository name>` was invalidated,
1213 1213 'repository': <repository name>
1214 1214 }
1215 1215 error : null
1216 1216
1217 1217 Example error output:
1218 1218
1219 1219 .. code-block:: bash
1220 1220
1221 1221 id : <id_given_in_input>
1222 1222 result : null
1223 1223 error : {
1224 1224 'Error occurred during cache invalidation action'
1225 1225 }
1226 1226
1227 1227 """
1228 1228
1229 1229 repo = get_repo_or_error(repoid)
1230 1230 if not has_superadmin_permission(apiuser):
1231 1231 _perms = ('repository.admin', 'repository.write',)
1232 1232 validate_repo_permissions(apiuser, repoid, repo, _perms)
1233 1233
1234 1234 delete = Optional.extract(delete_keys)
1235 1235 try:
1236 1236 ScmModel().mark_for_invalidation(repo.repo_name, delete=delete)
1237 1237 return {
1238 1238 'msg': 'Cache for repository `%s` was invalidated' % (repoid,),
1239 1239 'repository': repo.repo_name
1240 1240 }
1241 1241 except Exception:
1242 1242 log.exception(
1243 1243 "Exception occurred while trying to invalidate repo cache")
1244 1244 raise JSONRPCError(
1245 1245 'Error occurred during cache invalidation action'
1246 1246 )
1247 1247
1248 1248
1249 1249 #TODO: marcink, change name ?
1250 1250 @jsonrpc_method()
1251 1251 def lock(request, apiuser, repoid, locked=Optional(None),
1252 1252 userid=Optional(OAttr('apiuser'))):
1253 1253 """
1254 1254 Sets the lock state of the specified |repo| by the given user.
1255 1255 From more information, see :ref:`repo-locking`.
1256 1256
1257 1257 * If the ``userid`` option is not set, the repository is locked to the
1258 1258 user who called the method.
1259 1259 * If the ``locked`` parameter is not set, the current lock state of the
1260 1260 repository is displayed.
1261 1261
1262 1262 This command can only be run using an |authtoken| with admin rights to
1263 1263 the specified repository.
1264 1264
1265 1265 This command takes the following options:
1266 1266
1267 1267 :param apiuser: This is filled automatically from the |authtoken|.
1268 1268 :type apiuser: AuthUser
1269 1269 :param repoid: Sets the repository name or repository ID.
1270 1270 :type repoid: str or int
1271 1271 :param locked: Sets the lock state.
1272 1272 :type locked: Optional(``True`` | ``False``)
1273 1273 :param userid: Set the repository lock to this user.
1274 1274 :type userid: Optional(str or int)
1275 1275
1276 1276 Example error output:
1277 1277
1278 1278 .. code-block:: bash
1279 1279
1280 1280 id : <id_given_in_input>
1281 1281 result : {
1282 1282 'repo': '<reponame>',
1283 1283 'locked': <bool: lock state>,
1284 1284 'locked_since': <int: lock timestamp>,
1285 1285 'locked_by': <username of person who made the lock>,
1286 1286 'lock_reason': <str: reason for locking>,
1287 1287 'lock_state_changed': <bool: True if lock state has been changed in this request>,
1288 1288 'msg': 'Repo `<reponame>` locked by `<username>` on <timestamp>.'
1289 1289 or
1290 1290 'msg': 'Repo `<repository name>` not locked.'
1291 1291 or
1292 1292 'msg': 'User `<user name>` set lock state for repo `<repository name>` to `<new lock state>`'
1293 1293 }
1294 1294 error : null
1295 1295
1296 1296 Example error output:
1297 1297
1298 1298 .. code-block:: bash
1299 1299
1300 1300 id : <id_given_in_input>
1301 1301 result : null
1302 1302 error : {
1303 1303 'Error occurred locking repository `<reponame>`'
1304 1304 }
1305 1305 """
1306 1306
1307 1307 repo = get_repo_or_error(repoid)
1308 1308 if not has_superadmin_permission(apiuser):
1309 1309 # check if we have at least write permission for this repo !
1310 1310 _perms = ('repository.admin', 'repository.write',)
1311 1311 validate_repo_permissions(apiuser, repoid, repo, _perms)
1312 1312
1313 1313 # make sure normal user does not pass someone else userid,
1314 1314 # he is not allowed to do that
1315 1315 if not isinstance(userid, Optional) and userid != apiuser.user_id:
1316 1316 raise JSONRPCError('userid is not the same as your user')
1317 1317
1318 1318 if isinstance(userid, Optional):
1319 1319 userid = apiuser.user_id
1320 1320
1321 1321 user = get_user_or_error(userid)
1322 1322
1323 1323 if isinstance(locked, Optional):
1324 1324 lockobj = repo.locked
1325 1325
1326 1326 if lockobj[0] is None:
1327 1327 _d = {
1328 1328 'repo': repo.repo_name,
1329 1329 'locked': False,
1330 1330 'locked_since': None,
1331 1331 'locked_by': None,
1332 1332 'lock_reason': None,
1333 1333 'lock_state_changed': False,
1334 1334 'msg': 'Repo `%s` not locked.' % repo.repo_name
1335 1335 }
1336 1336 return _d
1337 1337 else:
1338 1338 _user_id, _time, _reason = lockobj
1339 1339 lock_user = get_user_or_error(userid)
1340 1340 _d = {
1341 1341 'repo': repo.repo_name,
1342 1342 'locked': True,
1343 1343 'locked_since': _time,
1344 1344 'locked_by': lock_user.username,
1345 1345 'lock_reason': _reason,
1346 1346 'lock_state_changed': False,
1347 1347 'msg': ('Repo `%s` locked by `%s` on `%s`.'
1348 1348 % (repo.repo_name, lock_user.username,
1349 1349 json.dumps(time_to_datetime(_time))))
1350 1350 }
1351 1351 return _d
1352 1352
1353 1353 # force locked state through a flag
1354 1354 else:
1355 1355 locked = str2bool(locked)
1356 1356 lock_reason = Repository.LOCK_API
1357 1357 try:
1358 1358 if locked:
1359 1359 lock_time = time.time()
1360 1360 Repository.lock(repo, user.user_id, lock_time, lock_reason)
1361 1361 else:
1362 1362 lock_time = None
1363 1363 Repository.unlock(repo)
1364 1364 _d = {
1365 1365 'repo': repo.repo_name,
1366 1366 'locked': locked,
1367 1367 'locked_since': lock_time,
1368 1368 'locked_by': user.username,
1369 1369 'lock_reason': lock_reason,
1370 1370 'lock_state_changed': True,
1371 1371 'msg': ('User `%s` set lock state for repo `%s` to `%s`'
1372 1372 % (user.username, repo.repo_name, locked))
1373 1373 }
1374 1374 return _d
1375 1375 except Exception:
1376 1376 log.exception(
1377 1377 "Exception occurred while trying to lock repository")
1378 1378 raise JSONRPCError(
1379 1379 'Error occurred locking repository `%s`' % repo.repo_name
1380 1380 )
1381 1381
1382 1382
1383 1383 @jsonrpc_method()
1384 1384 def comment_commit(
1385 1385 request, apiuser, repoid, commit_id, message,
1386 1386 userid=Optional(OAttr('apiuser')), status=Optional(None)):
1387 1387 """
1388 1388 Set a commit comment, and optionally change the status of the commit.
1389 1389
1390 1390 :param apiuser: This is filled automatically from the |authtoken|.
1391 1391 :type apiuser: AuthUser
1392 1392 :param repoid: Set the repository name or repository ID.
1393 1393 :type repoid: str or int
1394 1394 :param commit_id: Specify the commit_id for which to set a comment.
1395 1395 :type commit_id: str
1396 1396 :param message: The comment text.
1397 1397 :type message: str
1398 1398 :param userid: Set the user name of the comment creator.
1399 1399 :type userid: Optional(str or int)
1400 1400 :param status: status, one of 'not_reviewed', 'approved', 'rejected',
1401 1401 'under_review'
1402 1402 :type status: str
1403 1403
1404 1404 Example error output:
1405 1405
1406 1406 .. code-block:: json
1407 1407
1408 1408 {
1409 1409 "id" : <id_given_in_input>,
1410 1410 "result" : {
1411 1411 "msg": "Commented on commit `<commit_id>` for repository `<repoid>`",
1412 1412 "status_change": null or <status>,
1413 1413 "success": true
1414 1414 },
1415 1415 "error" : null
1416 1416 }
1417 1417
1418 1418 """
1419 1419 repo = get_repo_or_error(repoid)
1420 1420 if not has_superadmin_permission(apiuser):
1421 1421 _perms = ('repository.read', 'repository.write', 'repository.admin')
1422 1422 validate_repo_permissions(apiuser, repoid, repo, _perms)
1423 1423
1424 1424 if isinstance(userid, Optional):
1425 1425 userid = apiuser.user_id
1426 1426
1427 1427 user = get_user_or_error(userid)
1428 1428 status = Optional.extract(status)
1429 1429
1430 1430 allowed_statuses = [x[0] for x in ChangesetStatus.STATUSES]
1431 1431 if status and status not in allowed_statuses:
1432 1432 raise JSONRPCError('Bad status, must be on '
1433 1433 'of %s got %s' % (allowed_statuses, status,))
1434 1434
1435 1435 try:
1436 1436 rc_config = SettingsModel().get_all_settings()
1437 1437 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
1438 1438 status_change_label = ChangesetStatus.get_status_lbl(status)
1439 comm = ChangesetCommentsModel().create(
1439 comm = CommentsModel().create(
1440 1440 message, repo, user, commit_id=commit_id,
1441 1441 status_change=status_change_label,
1442 1442 status_change_type=status,
1443 1443 renderer=renderer)
1444 1444 if status:
1445 1445 # also do a status change
1446 1446 try:
1447 1447 ChangesetStatusModel().set_status(
1448 1448 repo, status, user, comm, revision=commit_id,
1449 1449 dont_allow_on_closed_pull_request=True
1450 1450 )
1451 1451 except StatusChangeOnClosedPullRequestError:
1452 1452 log.exception(
1453 1453 "Exception occurred while trying to change repo commit status")
1454 1454 msg = ('Changing status on a changeset associated with '
1455 1455 'a closed pull request is not allowed')
1456 1456 raise JSONRPCError(msg)
1457 1457
1458 1458 Session().commit()
1459 1459 return {
1460 1460 'msg': (
1461 1461 'Commented on commit `%s` for repository `%s`' % (
1462 1462 comm.revision, repo.repo_name)),
1463 1463 'status_change': status,
1464 1464 'success': True,
1465 1465 }
1466 1466 except JSONRPCError:
1467 1467 # catch any inside errors, and re-raise them to prevent from
1468 1468 # below global catch to silence them
1469 1469 raise
1470 1470 except Exception:
1471 1471 log.exception("Exception occurred while trying to comment on commit")
1472 1472 raise JSONRPCError(
1473 1473 'failed to set comment on repository `%s`' % (repo.repo_name,)
1474 1474 )
1475 1475
1476 1476
1477 1477 @jsonrpc_method()
1478 1478 def grant_user_permission(request, apiuser, repoid, userid, perm):
1479 1479 """
1480 1480 Grant permissions for the specified user on the given repository,
1481 1481 or update existing permissions if found.
1482 1482
1483 1483 This command can only be run using an |authtoken| with admin
1484 1484 permissions on the |repo|.
1485 1485
1486 1486 :param apiuser: This is filled automatically from the |authtoken|.
1487 1487 :type apiuser: AuthUser
1488 1488 :param repoid: Set the repository name or repository ID.
1489 1489 :type repoid: str or int
1490 1490 :param userid: Set the user name.
1491 1491 :type userid: str
1492 1492 :param perm: Set the user permissions, using the following format
1493 1493 ``(repository.(none|read|write|admin))``
1494 1494 :type perm: str
1495 1495
1496 1496 Example output:
1497 1497
1498 1498 .. code-block:: bash
1499 1499
1500 1500 id : <id_given_in_input>
1501 1501 result: {
1502 1502 "msg" : "Granted perm: `<perm>` for user: `<username>` in repo: `<reponame>`",
1503 1503 "success": true
1504 1504 }
1505 1505 error: null
1506 1506 """
1507 1507
1508 1508 repo = get_repo_or_error(repoid)
1509 1509 user = get_user_or_error(userid)
1510 1510 perm = get_perm_or_error(perm)
1511 1511 if not has_superadmin_permission(apiuser):
1512 1512 _perms = ('repository.admin',)
1513 1513 validate_repo_permissions(apiuser, repoid, repo, _perms)
1514 1514
1515 1515 try:
1516 1516
1517 1517 RepoModel().grant_user_permission(repo=repo, user=user, perm=perm)
1518 1518
1519 1519 Session().commit()
1520 1520 return {
1521 1521 'msg': 'Granted perm: `%s` for user: `%s` in repo: `%s`' % (
1522 1522 perm.permission_name, user.username, repo.repo_name
1523 1523 ),
1524 1524 'success': True
1525 1525 }
1526 1526 except Exception:
1527 1527 log.exception(
1528 1528 "Exception occurred while trying edit permissions for repo")
1529 1529 raise JSONRPCError(
1530 1530 'failed to edit permission for user: `%s` in repo: `%s`' % (
1531 1531 userid, repoid
1532 1532 )
1533 1533 )
1534 1534
1535 1535
1536 1536 @jsonrpc_method()
1537 1537 def revoke_user_permission(request, apiuser, repoid, userid):
1538 1538 """
1539 1539 Revoke permission for a user on the specified repository.
1540 1540
1541 1541 This command can only be run using an |authtoken| with admin
1542 1542 permissions on the |repo|.
1543 1543
1544 1544 :param apiuser: This is filled automatically from the |authtoken|.
1545 1545 :type apiuser: AuthUser
1546 1546 :param repoid: Set the repository name or repository ID.
1547 1547 :type repoid: str or int
1548 1548 :param userid: Set the user name of revoked user.
1549 1549 :type userid: str or int
1550 1550
1551 1551 Example error output:
1552 1552
1553 1553 .. code-block:: bash
1554 1554
1555 1555 id : <id_given_in_input>
1556 1556 result: {
1557 1557 "msg" : "Revoked perm for user: `<username>` in repo: `<reponame>`",
1558 1558 "success": true
1559 1559 }
1560 1560 error: null
1561 1561 """
1562 1562
1563 1563 repo = get_repo_or_error(repoid)
1564 1564 user = get_user_or_error(userid)
1565 1565 if not has_superadmin_permission(apiuser):
1566 1566 _perms = ('repository.admin',)
1567 1567 validate_repo_permissions(apiuser, repoid, repo, _perms)
1568 1568
1569 1569 try:
1570 1570 RepoModel().revoke_user_permission(repo=repo, user=user)
1571 1571 Session().commit()
1572 1572 return {
1573 1573 'msg': 'Revoked perm for user: `%s` in repo: `%s`' % (
1574 1574 user.username, repo.repo_name
1575 1575 ),
1576 1576 'success': True
1577 1577 }
1578 1578 except Exception:
1579 1579 log.exception(
1580 1580 "Exception occurred while trying revoke permissions to repo")
1581 1581 raise JSONRPCError(
1582 1582 'failed to edit permission for user: `%s` in repo: `%s`' % (
1583 1583 userid, repoid
1584 1584 )
1585 1585 )
1586 1586
1587 1587
1588 1588 @jsonrpc_method()
1589 1589 def grant_user_group_permission(request, apiuser, repoid, usergroupid, perm):
1590 1590 """
1591 1591 Grant permission for a user group on the specified repository,
1592 1592 or update existing permissions.
1593 1593
1594 1594 This command can only be run using an |authtoken| with admin
1595 1595 permissions on the |repo|.
1596 1596
1597 1597 :param apiuser: This is filled automatically from the |authtoken|.
1598 1598 :type apiuser: AuthUser
1599 1599 :param repoid: Set the repository name or repository ID.
1600 1600 :type repoid: str or int
1601 1601 :param usergroupid: Specify the ID of the user group.
1602 1602 :type usergroupid: str or int
1603 1603 :param perm: Set the user group permissions using the following
1604 1604 format: (repository.(none|read|write|admin))
1605 1605 :type perm: str
1606 1606
1607 1607 Example output:
1608 1608
1609 1609 .. code-block:: bash
1610 1610
1611 1611 id : <id_given_in_input>
1612 1612 result : {
1613 1613 "msg" : "Granted perm: `<perm>` for group: `<usersgroupname>` in repo: `<reponame>`",
1614 1614 "success": true
1615 1615
1616 1616 }
1617 1617 error : null
1618 1618
1619 1619 Example error output:
1620 1620
1621 1621 .. code-block:: bash
1622 1622
1623 1623 id : <id_given_in_input>
1624 1624 result : null
1625 1625 error : {
1626 1626 "failed to edit permission for user group: `<usergroup>` in repo `<repo>`'
1627 1627 }
1628 1628
1629 1629 """
1630 1630
1631 1631 repo = get_repo_or_error(repoid)
1632 1632 perm = get_perm_or_error(perm)
1633 1633 if not has_superadmin_permission(apiuser):
1634 1634 _perms = ('repository.admin',)
1635 1635 validate_repo_permissions(apiuser, repoid, repo, _perms)
1636 1636
1637 1637 user_group = get_user_group_or_error(usergroupid)
1638 1638 if not has_superadmin_permission(apiuser):
1639 1639 # check if we have at least read permission for this user group !
1640 1640 _perms = ('usergroup.read', 'usergroup.write', 'usergroup.admin',)
1641 1641 if not HasUserGroupPermissionAnyApi(*_perms)(
1642 1642 user=apiuser, user_group_name=user_group.users_group_name):
1643 1643 raise JSONRPCError(
1644 1644 'user group `%s` does not exist' % (usergroupid,))
1645 1645
1646 1646 try:
1647 1647 RepoModel().grant_user_group_permission(
1648 1648 repo=repo, group_name=user_group, perm=perm)
1649 1649
1650 1650 Session().commit()
1651 1651 return {
1652 1652 'msg': 'Granted perm: `%s` for user group: `%s` in '
1653 1653 'repo: `%s`' % (
1654 1654 perm.permission_name, user_group.users_group_name,
1655 1655 repo.repo_name
1656 1656 ),
1657 1657 'success': True
1658 1658 }
1659 1659 except Exception:
1660 1660 log.exception(
1661 1661 "Exception occurred while trying change permission on repo")
1662 1662 raise JSONRPCError(
1663 1663 'failed to edit permission for user group: `%s` in '
1664 1664 'repo: `%s`' % (
1665 1665 usergroupid, repo.repo_name
1666 1666 )
1667 1667 )
1668 1668
1669 1669
1670 1670 @jsonrpc_method()
1671 1671 def revoke_user_group_permission(request, apiuser, repoid, usergroupid):
1672 1672 """
1673 1673 Revoke the permissions of a user group on a given repository.
1674 1674
1675 1675 This command can only be run using an |authtoken| with admin
1676 1676 permissions on the |repo|.
1677 1677
1678 1678 :param apiuser: This is filled automatically from the |authtoken|.
1679 1679 :type apiuser: AuthUser
1680 1680 :param repoid: Set the repository name or repository ID.
1681 1681 :type repoid: str or int
1682 1682 :param usergroupid: Specify the user group ID.
1683 1683 :type usergroupid: str or int
1684 1684
1685 1685 Example output:
1686 1686
1687 1687 .. code-block:: bash
1688 1688
1689 1689 id : <id_given_in_input>
1690 1690 result: {
1691 1691 "msg" : "Revoked perm for group: `<usersgroupname>` in repo: `<reponame>`",
1692 1692 "success": true
1693 1693 }
1694 1694 error: null
1695 1695 """
1696 1696
1697 1697 repo = get_repo_or_error(repoid)
1698 1698 if not has_superadmin_permission(apiuser):
1699 1699 _perms = ('repository.admin',)
1700 1700 validate_repo_permissions(apiuser, repoid, repo, _perms)
1701 1701
1702 1702 user_group = get_user_group_or_error(usergroupid)
1703 1703 if not has_superadmin_permission(apiuser):
1704 1704 # check if we have at least read permission for this user group !
1705 1705 _perms = ('usergroup.read', 'usergroup.write', 'usergroup.admin',)
1706 1706 if not HasUserGroupPermissionAnyApi(*_perms)(
1707 1707 user=apiuser, user_group_name=user_group.users_group_name):
1708 1708 raise JSONRPCError(
1709 1709 'user group `%s` does not exist' % (usergroupid,))
1710 1710
1711 1711 try:
1712 1712 RepoModel().revoke_user_group_permission(
1713 1713 repo=repo, group_name=user_group)
1714 1714
1715 1715 Session().commit()
1716 1716 return {
1717 1717 'msg': 'Revoked perm for user group: `%s` in repo: `%s`' % (
1718 1718 user_group.users_group_name, repo.repo_name
1719 1719 ),
1720 1720 'success': True
1721 1721 }
1722 1722 except Exception:
1723 1723 log.exception("Exception occurred while trying revoke "
1724 1724 "user group permission on repo")
1725 1725 raise JSONRPCError(
1726 1726 'failed to edit permission for user group: `%s` in '
1727 1727 'repo: `%s`' % (
1728 1728 user_group.users_group_name, repo.repo_name
1729 1729 )
1730 1730 )
1731 1731
1732 1732
1733 1733 @jsonrpc_method()
1734 1734 def pull(request, apiuser, repoid):
1735 1735 """
1736 1736 Triggers a pull on the given repository from a remote location. You
1737 1737 can use this to keep remote repositories up-to-date.
1738 1738
1739 1739 This command can only be run using an |authtoken| with admin
1740 1740 rights to the specified repository. For more information,
1741 1741 see :ref:`config-token-ref`.
1742 1742
1743 1743 This command takes the following options:
1744 1744
1745 1745 :param apiuser: This is filled automatically from the |authtoken|.
1746 1746 :type apiuser: AuthUser
1747 1747 :param repoid: The repository name or repository ID.
1748 1748 :type repoid: str or int
1749 1749
1750 1750 Example output:
1751 1751
1752 1752 .. code-block:: bash
1753 1753
1754 1754 id : <id_given_in_input>
1755 1755 result : {
1756 1756 "msg": "Pulled from `<repository name>`"
1757 1757 "repository": "<repository name>"
1758 1758 }
1759 1759 error : null
1760 1760
1761 1761 Example error output:
1762 1762
1763 1763 .. code-block:: bash
1764 1764
1765 1765 id : <id_given_in_input>
1766 1766 result : null
1767 1767 error : {
1768 1768 "Unable to pull changes from `<reponame>`"
1769 1769 }
1770 1770
1771 1771 """
1772 1772
1773 1773 repo = get_repo_or_error(repoid)
1774 1774 if not has_superadmin_permission(apiuser):
1775 1775 _perms = ('repository.admin',)
1776 1776 validate_repo_permissions(apiuser, repoid, repo, _perms)
1777 1777
1778 1778 try:
1779 1779 ScmModel().pull_changes(repo.repo_name, apiuser.username)
1780 1780 return {
1781 1781 'msg': 'Pulled from `%s`' % repo.repo_name,
1782 1782 'repository': repo.repo_name
1783 1783 }
1784 1784 except Exception:
1785 1785 log.exception("Exception occurred while trying to "
1786 1786 "pull changes from remote location")
1787 1787 raise JSONRPCError(
1788 1788 'Unable to pull changes from `%s`' % repo.repo_name
1789 1789 )
1790 1790
1791 1791
1792 1792 @jsonrpc_method()
1793 1793 def strip(request, apiuser, repoid, revision, branch):
1794 1794 """
1795 1795 Strips the given revision from the specified repository.
1796 1796
1797 1797 * This will remove the revision and all of its decendants.
1798 1798
1799 1799 This command can only be run using an |authtoken| with admin rights to
1800 1800 the specified repository.
1801 1801
1802 1802 This command takes the following options:
1803 1803
1804 1804 :param apiuser: This is filled automatically from the |authtoken|.
1805 1805 :type apiuser: AuthUser
1806 1806 :param repoid: The repository name or repository ID.
1807 1807 :type repoid: str or int
1808 1808 :param revision: The revision you wish to strip.
1809 1809 :type revision: str
1810 1810 :param branch: The branch from which to strip the revision.
1811 1811 :type branch: str
1812 1812
1813 1813 Example output:
1814 1814
1815 1815 .. code-block:: bash
1816 1816
1817 1817 id : <id_given_in_input>
1818 1818 result : {
1819 1819 "msg": "'Stripped commit <commit_hash> from repo `<repository name>`'"
1820 1820 "repository": "<repository name>"
1821 1821 }
1822 1822 error : null
1823 1823
1824 1824 Example error output:
1825 1825
1826 1826 .. code-block:: bash
1827 1827
1828 1828 id : <id_given_in_input>
1829 1829 result : null
1830 1830 error : {
1831 1831 "Unable to strip commit <commit_hash> from repo `<repository name>`"
1832 1832 }
1833 1833
1834 1834 """
1835 1835
1836 1836 repo = get_repo_or_error(repoid)
1837 1837 if not has_superadmin_permission(apiuser):
1838 1838 _perms = ('repository.admin',)
1839 1839 validate_repo_permissions(apiuser, repoid, repo, _perms)
1840 1840
1841 1841 try:
1842 1842 ScmModel().strip(repo, revision, branch)
1843 1843 return {
1844 1844 'msg': 'Stripped commit %s from repo `%s`' % (
1845 1845 revision, repo.repo_name),
1846 1846 'repository': repo.repo_name
1847 1847 }
1848 1848 except Exception:
1849 1849 log.exception("Exception while trying to strip")
1850 1850 raise JSONRPCError(
1851 1851 'Unable to strip commit %s from repo `%s`' % (
1852 1852 revision, repo.repo_name)
1853 1853 )
1854 1854
1855 1855
1856 1856 @jsonrpc_method()
1857 1857 def get_repo_settings(request, apiuser, repoid, key=Optional(None)):
1858 1858 """
1859 1859 Returns all settings for a repository. If key is given it only returns the
1860 1860 setting identified by the key or null.
1861 1861
1862 1862 :param apiuser: This is filled automatically from the |authtoken|.
1863 1863 :type apiuser: AuthUser
1864 1864 :param repoid: The repository name or repository id.
1865 1865 :type repoid: str or int
1866 1866 :param key: Key of the setting to return.
1867 1867 :type: key: Optional(str)
1868 1868
1869 1869 Example output:
1870 1870
1871 1871 .. code-block:: bash
1872 1872
1873 1873 {
1874 1874 "error": null,
1875 1875 "id": 237,
1876 1876 "result": {
1877 1877 "extensions_largefiles": true,
1878 1878 "hooks_changegroup_push_logger": true,
1879 1879 "hooks_changegroup_repo_size": false,
1880 1880 "hooks_outgoing_pull_logger": true,
1881 1881 "phases_publish": "True",
1882 1882 "rhodecode_hg_use_rebase_for_merging": true,
1883 1883 "rhodecode_pr_merge_enabled": true,
1884 1884 "rhodecode_use_outdated_comments": true
1885 1885 }
1886 1886 }
1887 1887 """
1888 1888
1889 1889 # Restrict access to this api method to admins only.
1890 1890 if not has_superadmin_permission(apiuser):
1891 1891 raise JSONRPCForbidden()
1892 1892
1893 1893 try:
1894 1894 repo = get_repo_or_error(repoid)
1895 1895 settings_model = VcsSettingsModel(repo=repo)
1896 1896 settings = settings_model.get_global_settings()
1897 1897 settings.update(settings_model.get_repo_settings())
1898 1898
1899 1899 # If only a single setting is requested fetch it from all settings.
1900 1900 key = Optional.extract(key)
1901 1901 if key is not None:
1902 1902 settings = settings.get(key, None)
1903 1903 except Exception:
1904 1904 msg = 'Failed to fetch settings for repository `{}`'.format(repoid)
1905 1905 log.exception(msg)
1906 1906 raise JSONRPCError(msg)
1907 1907
1908 1908 return settings
1909 1909
1910 1910
1911 1911 @jsonrpc_method()
1912 1912 def set_repo_settings(request, apiuser, repoid, settings):
1913 1913 """
1914 1914 Update repository settings. Returns true on success.
1915 1915
1916 1916 :param apiuser: This is filled automatically from the |authtoken|.
1917 1917 :type apiuser: AuthUser
1918 1918 :param repoid: The repository name or repository id.
1919 1919 :type repoid: str or int
1920 1920 :param settings: The new settings for the repository.
1921 1921 :type: settings: dict
1922 1922
1923 1923 Example output:
1924 1924
1925 1925 .. code-block:: bash
1926 1926
1927 1927 {
1928 1928 "error": null,
1929 1929 "id": 237,
1930 1930 "result": true
1931 1931 }
1932 1932 """
1933 1933 # Restrict access to this api method to admins only.
1934 1934 if not has_superadmin_permission(apiuser):
1935 1935 raise JSONRPCForbidden()
1936 1936
1937 1937 if type(settings) is not dict:
1938 1938 raise JSONRPCError('Settings have to be a JSON Object.')
1939 1939
1940 1940 try:
1941 1941 settings_model = VcsSettingsModel(repo=repoid)
1942 1942
1943 1943 # Merge global, repo and incoming settings.
1944 1944 new_settings = settings_model.get_global_settings()
1945 1945 new_settings.update(settings_model.get_repo_settings())
1946 1946 new_settings.update(settings)
1947 1947
1948 1948 # Update the settings.
1949 1949 inherit_global_settings = new_settings.get(
1950 1950 'inherit_global_settings', False)
1951 1951 settings_model.create_or_update_repo_settings(
1952 1952 new_settings, inherit_global_settings=inherit_global_settings)
1953 1953 Session().commit()
1954 1954 except Exception:
1955 1955 msg = 'Failed to update settings for repository `{}`'.format(repoid)
1956 1956 log.exception(msg)
1957 1957 raise JSONRPCError(msg)
1958 1958
1959 1959 # Indicate success.
1960 1960 return True
@@ -1,468 +1,468 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2013-2017 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 my account controller for RhodeCode admin
24 24 """
25 25
26 26 import logging
27 27 import datetime
28 28
29 29 import formencode
30 30 from formencode import htmlfill
31 31 from pyramid.threadlocal import get_current_registry
32 32 from pylons import request, tmpl_context as c, url, session
33 33 from pylons.controllers.util import redirect
34 34 from pylons.i18n.translation import _
35 35 from sqlalchemy.orm import joinedload
36 36 from webob.exc import HTTPBadGateway
37 37
38 38 from rhodecode import forms
39 39 from rhodecode.lib import helpers as h
40 40 from rhodecode.lib import auth
41 41 from rhodecode.lib.auth import (
42 42 LoginRequired, NotAnonymous, AuthUser, generate_auth_token)
43 43 from rhodecode.lib.base import BaseController, render
44 44 from rhodecode.lib.utils import jsonify
45 45 from rhodecode.lib.utils2 import safe_int, md5, str2bool
46 46 from rhodecode.lib.ext_json import json
47 47 from rhodecode.lib.channelstream import channelstream_request, \
48 48 ChannelstreamException
49 49
50 50 from rhodecode.model.validation_schema.schemas import user_schema
51 51 from rhodecode.model.db import (
52 52 Repository, PullRequest, UserEmailMap, User, UserFollowing)
53 53 from rhodecode.model.forms import UserForm
54 54 from rhodecode.model.scm import RepoList
55 55 from rhodecode.model.user import UserModel
56 56 from rhodecode.model.repo import RepoModel
57 57 from rhodecode.model.auth_token import AuthTokenModel
58 58 from rhodecode.model.meta import Session
59 59 from rhodecode.model.pull_request import PullRequestModel
60 from rhodecode.model.comment import ChangesetCommentsModel
60 from rhodecode.model.comment import CommentsModel
61 61
62 62 log = logging.getLogger(__name__)
63 63
64 64
65 65 class MyAccountController(BaseController):
66 66 """REST Controller styled on the Atom Publishing Protocol"""
67 67 # To properly map this controller, ensure your config/routing.py
68 68 # file has a resource setup:
69 69 # map.resource('setting', 'settings', controller='admin/settings',
70 70 # path_prefix='/admin', name_prefix='admin_')
71 71
72 72 @LoginRequired()
73 73 @NotAnonymous()
74 74 def __before__(self):
75 75 super(MyAccountController, self).__before__()
76 76
77 77 def __load_data(self):
78 78 c.user = User.get(c.rhodecode_user.user_id)
79 79 if c.user.username == User.DEFAULT_USER:
80 80 h.flash(_("You can't edit this user since it's"
81 81 " crucial for entire application"), category='warning')
82 82 return redirect(url('users'))
83 83
84 84 c.auth_user = AuthUser(
85 85 user_id=c.rhodecode_user.user_id, ip_addr=self.ip_addr)
86 86
87 87 def _load_my_repos_data(self, watched=False):
88 88 if watched:
89 89 admin = False
90 90 follows_repos = Session().query(UserFollowing)\
91 91 .filter(UserFollowing.user_id == c.rhodecode_user.user_id)\
92 92 .options(joinedload(UserFollowing.follows_repository))\
93 93 .all()
94 94 repo_list = [x.follows_repository for x in follows_repos]
95 95 else:
96 96 admin = True
97 97 repo_list = Repository.get_all_repos(
98 98 user_id=c.rhodecode_user.user_id)
99 99 repo_list = RepoList(repo_list, perm_set=[
100 100 'repository.read', 'repository.write', 'repository.admin'])
101 101
102 102 repos_data = RepoModel().get_repos_as_dict(
103 103 repo_list=repo_list, admin=admin)
104 104 # json used to render the grid
105 105 return json.dumps(repos_data)
106 106
107 107 @auth.CSRFRequired()
108 108 def my_account_update(self):
109 109 """
110 110 POST /_admin/my_account Updates info of my account
111 111 """
112 112 # url('my_account')
113 113 c.active = 'profile_edit'
114 114 self.__load_data()
115 115 c.perm_user = c.auth_user
116 116 c.extern_type = c.user.extern_type
117 117 c.extern_name = c.user.extern_name
118 118
119 119 defaults = c.user.get_dict()
120 120 update = False
121 121 _form = UserForm(edit=True,
122 122 old_data={'user_id': c.rhodecode_user.user_id,
123 123 'email': c.rhodecode_user.email})()
124 124 form_result = {}
125 125 try:
126 126 post_data = dict(request.POST)
127 127 post_data['new_password'] = ''
128 128 post_data['password_confirmation'] = ''
129 129 form_result = _form.to_python(post_data)
130 130 # skip updating those attrs for my account
131 131 skip_attrs = ['admin', 'active', 'extern_type', 'extern_name',
132 132 'new_password', 'password_confirmation']
133 133 # TODO: plugin should define if username can be updated
134 134 if c.extern_type != "rhodecode":
135 135 # forbid updating username for external accounts
136 136 skip_attrs.append('username')
137 137
138 138 UserModel().update_user(
139 139 c.rhodecode_user.user_id, skip_attrs=skip_attrs, **form_result)
140 140 h.flash(_('Your account was updated successfully'),
141 141 category='success')
142 142 Session().commit()
143 143 update = True
144 144
145 145 except formencode.Invalid as errors:
146 146 return htmlfill.render(
147 147 render('admin/my_account/my_account.mako'),
148 148 defaults=errors.value,
149 149 errors=errors.error_dict or {},
150 150 prefix_error=False,
151 151 encoding="UTF-8",
152 152 force_defaults=False)
153 153 except Exception:
154 154 log.exception("Exception updating user")
155 155 h.flash(_('Error occurred during update of user %s')
156 156 % form_result.get('username'), category='error')
157 157
158 158 if update:
159 159 return redirect('my_account')
160 160
161 161 return htmlfill.render(
162 162 render('admin/my_account/my_account.mako'),
163 163 defaults=defaults,
164 164 encoding="UTF-8",
165 165 force_defaults=False
166 166 )
167 167
168 168 def my_account(self):
169 169 """
170 170 GET /_admin/my_account Displays info about my account
171 171 """
172 172 # url('my_account')
173 173 c.active = 'profile'
174 174 self.__load_data()
175 175
176 176 defaults = c.user.get_dict()
177 177 return htmlfill.render(
178 178 render('admin/my_account/my_account.mako'),
179 179 defaults=defaults, encoding="UTF-8", force_defaults=False)
180 180
181 181 def my_account_edit(self):
182 182 """
183 183 GET /_admin/my_account/edit Displays edit form of my account
184 184 """
185 185 c.active = 'profile_edit'
186 186 self.__load_data()
187 187 c.perm_user = c.auth_user
188 188 c.extern_type = c.user.extern_type
189 189 c.extern_name = c.user.extern_name
190 190
191 191 defaults = c.user.get_dict()
192 192 return htmlfill.render(
193 193 render('admin/my_account/my_account.mako'),
194 194 defaults=defaults,
195 195 encoding="UTF-8",
196 196 force_defaults=False
197 197 )
198 198
199 199 @auth.CSRFRequired(except_methods=['GET'])
200 200 def my_account_password(self):
201 201 c.active = 'password'
202 202 self.__load_data()
203 203 c.extern_type = c.user.extern_type
204 204
205 205 schema = user_schema.ChangePasswordSchema().bind(
206 206 username=c.rhodecode_user.username)
207 207
208 208 form = forms.Form(schema,
209 209 buttons=(forms.buttons.save, forms.buttons.reset))
210 210
211 211 if request.method == 'POST' and c.extern_type == 'rhodecode':
212 212 controls = request.POST.items()
213 213 try:
214 214 valid_data = form.validate(controls)
215 215 UserModel().update_user(c.rhodecode_user.user_id, **valid_data)
216 216 instance = c.rhodecode_user.get_instance()
217 217 instance.update_userdata(force_password_change=False)
218 218 Session().commit()
219 219 except forms.ValidationFailure as e:
220 220 request.session.flash(
221 221 _('Error occurred during update of user password'),
222 222 queue='error')
223 223 form = e
224 224 except Exception:
225 225 log.exception("Exception updating password")
226 226 request.session.flash(
227 227 _('Error occurred during update of user password'),
228 228 queue='error')
229 229 else:
230 230 session.setdefault('rhodecode_user', {}).update(
231 231 {'password': md5(instance.password)})
232 232 session.save()
233 233 request.session.flash(
234 234 _("Successfully updated password"), queue='success')
235 235 return redirect(url('my_account_password'))
236 236
237 237 c.form = form
238 238 return render('admin/my_account/my_account.mako')
239 239
240 240 def my_account_repos(self):
241 241 c.active = 'repos'
242 242 self.__load_data()
243 243
244 244 # json used to render the grid
245 245 c.data = self._load_my_repos_data()
246 246 return render('admin/my_account/my_account.mako')
247 247
248 248 def my_account_watched(self):
249 249 c.active = 'watched'
250 250 self.__load_data()
251 251
252 252 # json used to render the grid
253 253 c.data = self._load_my_repos_data(watched=True)
254 254 return render('admin/my_account/my_account.mako')
255 255
256 256 def my_account_perms(self):
257 257 c.active = 'perms'
258 258 self.__load_data()
259 259 c.perm_user = c.auth_user
260 260
261 261 return render('admin/my_account/my_account.mako')
262 262
263 263 def my_account_emails(self):
264 264 c.active = 'emails'
265 265 self.__load_data()
266 266
267 267 c.user_email_map = UserEmailMap.query()\
268 268 .filter(UserEmailMap.user == c.user).all()
269 269 return render('admin/my_account/my_account.mako')
270 270
271 271 @auth.CSRFRequired()
272 272 def my_account_emails_add(self):
273 273 email = request.POST.get('new_email')
274 274
275 275 try:
276 276 UserModel().add_extra_email(c.rhodecode_user.user_id, email)
277 277 Session().commit()
278 278 h.flash(_("Added new email address `%s` for user account") % email,
279 279 category='success')
280 280 except formencode.Invalid as error:
281 281 msg = error.error_dict['email']
282 282 h.flash(msg, category='error')
283 283 except Exception:
284 284 log.exception("Exception in my_account_emails")
285 285 h.flash(_('An error occurred during email saving'),
286 286 category='error')
287 287 return redirect(url('my_account_emails'))
288 288
289 289 @auth.CSRFRequired()
290 290 def my_account_emails_delete(self):
291 291 email_id = request.POST.get('del_email_id')
292 292 user_model = UserModel()
293 293 user_model.delete_extra_email(c.rhodecode_user.user_id, email_id)
294 294 Session().commit()
295 295 h.flash(_("Removed email address from user account"),
296 296 category='success')
297 297 return redirect(url('my_account_emails'))
298 298
299 299 def _extract_ordering(self, request):
300 300 column_index = safe_int(request.GET.get('order[0][column]'))
301 301 order_dir = request.GET.get('order[0][dir]', 'desc')
302 302 order_by = request.GET.get(
303 303 'columns[%s][data][sort]' % column_index, 'name_raw')
304 304 return order_by, order_dir
305 305
306 306 def _get_pull_requests_list(self, statuses):
307 307 start = safe_int(request.GET.get('start'), 0)
308 308 length = safe_int(request.GET.get('length'), c.visual.dashboard_items)
309 309 order_by, order_dir = self._extract_ordering(request)
310 310
311 311 pull_requests = PullRequestModel().get_im_participating_in(
312 312 user_id=c.rhodecode_user.user_id,
313 313 statuses=statuses,
314 314 offset=start, length=length, order_by=order_by,
315 315 order_dir=order_dir)
316 316
317 317 pull_requests_total_count = PullRequestModel().count_im_participating_in(
318 318 user_id=c.rhodecode_user.user_id, statuses=statuses)
319 319
320 320 from rhodecode.lib.utils import PartialRenderer
321 321 _render = PartialRenderer('data_table/_dt_elements.mako')
322 322 data = []
323 323 for pr in pull_requests:
324 324 repo_id = pr.target_repo_id
325 comments = ChangesetCommentsModel().get_all_comments(
325 comments = CommentsModel().get_all_comments(
326 326 repo_id, pull_request=pr)
327 327 owned = pr.user_id == c.rhodecode_user.user_id
328 328 status = pr.calculated_review_status()
329 329
330 330 data.append({
331 331 'target_repo': _render('pullrequest_target_repo',
332 332 pr.target_repo.repo_name),
333 333 'name': _render('pullrequest_name',
334 334 pr.pull_request_id, pr.target_repo.repo_name,
335 335 short=True),
336 336 'name_raw': pr.pull_request_id,
337 337 'status': _render('pullrequest_status', status),
338 338 'title': _render(
339 339 'pullrequest_title', pr.title, pr.description),
340 340 'description': h.escape(pr.description),
341 341 'updated_on': _render('pullrequest_updated_on',
342 342 h.datetime_to_time(pr.updated_on)),
343 343 'updated_on_raw': h.datetime_to_time(pr.updated_on),
344 344 'created_on': _render('pullrequest_updated_on',
345 345 h.datetime_to_time(pr.created_on)),
346 346 'created_on_raw': h.datetime_to_time(pr.created_on),
347 347 'author': _render('pullrequest_author',
348 348 pr.author.full_contact, ),
349 349 'author_raw': pr.author.full_name,
350 350 'comments': _render('pullrequest_comments', len(comments)),
351 351 'comments_raw': len(comments),
352 352 'closed': pr.is_closed(),
353 353 'owned': owned
354 354 })
355 355 # json used to render the grid
356 356 data = ({
357 357 'data': data,
358 358 'recordsTotal': pull_requests_total_count,
359 359 'recordsFiltered': pull_requests_total_count,
360 360 })
361 361 return data
362 362
363 363 def my_account_pullrequests(self):
364 364 c.active = 'pullrequests'
365 365 self.__load_data()
366 366 c.show_closed = str2bool(request.GET.get('pr_show_closed'))
367 367
368 368 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
369 369 if c.show_closed:
370 370 statuses += [PullRequest.STATUS_CLOSED]
371 371 data = self._get_pull_requests_list(statuses)
372 372 if not request.is_xhr:
373 373 c.data_participate = json.dumps(data['data'])
374 374 c.records_total_participate = data['recordsTotal']
375 375 return render('admin/my_account/my_account.mako')
376 376 else:
377 377 return json.dumps(data)
378 378
379 379 def my_account_auth_tokens(self):
380 380 c.active = 'auth_tokens'
381 381 self.__load_data()
382 382 show_expired = True
383 383 c.lifetime_values = [
384 384 (str(-1), _('forever')),
385 385 (str(5), _('5 minutes')),
386 386 (str(60), _('1 hour')),
387 387 (str(60 * 24), _('1 day')),
388 388 (str(60 * 24 * 30), _('1 month')),
389 389 ]
390 390 c.lifetime_options = [(c.lifetime_values, _("Lifetime"))]
391 391 c.role_values = [(x, AuthTokenModel.cls._get_role_name(x))
392 392 for x in AuthTokenModel.cls.ROLES]
393 393 c.role_options = [(c.role_values, _("Role"))]
394 394 c.user_auth_tokens = AuthTokenModel().get_auth_tokens(
395 395 c.rhodecode_user.user_id, show_expired=show_expired)
396 396 return render('admin/my_account/my_account.mako')
397 397
398 398 @auth.CSRFRequired()
399 399 def my_account_auth_tokens_add(self):
400 400 lifetime = safe_int(request.POST.get('lifetime'), -1)
401 401 description = request.POST.get('description')
402 402 role = request.POST.get('role')
403 403 AuthTokenModel().create(c.rhodecode_user.user_id, description, lifetime,
404 404 role)
405 405 Session().commit()
406 406 h.flash(_("Auth token successfully created"), category='success')
407 407 return redirect(url('my_account_auth_tokens'))
408 408
409 409 @auth.CSRFRequired()
410 410 def my_account_auth_tokens_delete(self):
411 411 auth_token = request.POST.get('del_auth_token')
412 412 user_id = c.rhodecode_user.user_id
413 413 if request.POST.get('del_auth_token_builtin'):
414 414 user = User.get(user_id)
415 415 if user:
416 416 user.api_key = generate_auth_token(user.username)
417 417 Session().add(user)
418 418 Session().commit()
419 419 h.flash(_("Auth token successfully reset"), category='success')
420 420 elif auth_token:
421 421 AuthTokenModel().delete(auth_token, c.rhodecode_user.user_id)
422 422 Session().commit()
423 423 h.flash(_("Auth token successfully deleted"), category='success')
424 424
425 425 return redirect(url('my_account_auth_tokens'))
426 426
427 427 def my_notifications(self):
428 428 c.active = 'notifications'
429 429 return render('admin/my_account/my_account.mako')
430 430
431 431 @auth.CSRFRequired()
432 432 @jsonify
433 433 def my_notifications_toggle_visibility(self):
434 434 user = c.rhodecode_user.get_instance()
435 435 new_status = not user.user_data.get('notification_status', True)
436 436 user.update_userdata(notification_status=new_status)
437 437 Session().commit()
438 438 return user.user_data['notification_status']
439 439
440 440 @auth.CSRFRequired()
441 441 @jsonify
442 442 def my_account_notifications_test_channelstream(self):
443 443 message = 'Test message sent via Channelstream by user: {}, on {}'.format(
444 444 c.rhodecode_user.username, datetime.datetime.now())
445 445 payload = {
446 446 'type': 'message',
447 447 'timestamp': datetime.datetime.utcnow(),
448 448 'user': 'system',
449 449 #'channel': 'broadcast',
450 450 'pm_users': [c.rhodecode_user.username],
451 451 'message': {
452 452 'message': message,
453 453 'level': 'info',
454 454 'topic': '/notifications'
455 455 }
456 456 }
457 457
458 458 registry = get_current_registry()
459 459 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
460 460 channelstream_config = rhodecode_plugins.get('channelstream', {})
461 461
462 462 try:
463 463 channelstream_request(channelstream_config, [payload], '/message')
464 464 except ChannelstreamException as e:
465 465 log.exception('Failed to send channelstream data')
466 466 return {"response": 'ERROR: {}'.format(e.__class__.__name__)}
467 467 return {"response": 'Channelstream data sent. '
468 468 'You should see a new live message now.'}
@@ -1,470 +1,470 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 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 commit controller for RhodeCode showing changes between commits
23 23 """
24 24
25 25 import logging
26 26
27 27 from collections import defaultdict
28 28 from webob.exc import HTTPForbidden, HTTPBadRequest, HTTPNotFound
29 29
30 30 from pylons import tmpl_context as c, request, response
31 31 from pylons.i18n.translation import _
32 32 from pylons.controllers.util import redirect
33 33
34 34 from rhodecode.lib import auth
35 35 from rhodecode.lib import diffs, codeblocks
36 36 from rhodecode.lib.auth import (
37 37 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous)
38 38 from rhodecode.lib.base import BaseRepoController, render
39 39 from rhodecode.lib.compat import OrderedDict
40 40 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError
41 41 import rhodecode.lib.helpers as h
42 42 from rhodecode.lib.utils import action_logger, jsonify
43 43 from rhodecode.lib.utils2 import safe_unicode
44 44 from rhodecode.lib.vcs.backends.base import EmptyCommit
45 45 from rhodecode.lib.vcs.exceptions import (
46 46 RepositoryError, CommitDoesNotExistError, NodeDoesNotExistError)
47 47 from rhodecode.model.db import ChangesetComment, ChangesetStatus
48 48 from rhodecode.model.changeset_status import ChangesetStatusModel
49 from rhodecode.model.comment import ChangesetCommentsModel
49 from rhodecode.model.comment import CommentsModel
50 50 from rhodecode.model.meta import Session
51 51 from rhodecode.model.repo import RepoModel
52 52
53 53
54 54 log = logging.getLogger(__name__)
55 55
56 56
57 57 def _update_with_GET(params, GET):
58 58 for k in ['diff1', 'diff2', 'diff']:
59 59 params[k] += GET.getall(k)
60 60
61 61
62 62 def get_ignore_ws(fid, GET):
63 63 ig_ws_global = GET.get('ignorews')
64 64 ig_ws = filter(lambda k: k.startswith('WS'), GET.getall(fid))
65 65 if ig_ws:
66 66 try:
67 67 return int(ig_ws[0].split(':')[-1])
68 68 except Exception:
69 69 pass
70 70 return ig_ws_global
71 71
72 72
73 73 def _ignorews_url(GET, fileid=None):
74 74 fileid = str(fileid) if fileid else None
75 75 params = defaultdict(list)
76 76 _update_with_GET(params, GET)
77 77 label = _('Show whitespace')
78 78 tooltiplbl = _('Show whitespace for all diffs')
79 79 ig_ws = get_ignore_ws(fileid, GET)
80 80 ln_ctx = get_line_ctx(fileid, GET)
81 81
82 82 if ig_ws is None:
83 83 params['ignorews'] += [1]
84 84 label = _('Ignore whitespace')
85 85 tooltiplbl = _('Ignore whitespace for all diffs')
86 86 ctx_key = 'context'
87 87 ctx_val = ln_ctx
88 88
89 89 # if we have passed in ln_ctx pass it along to our params
90 90 if ln_ctx:
91 91 params[ctx_key] += [ctx_val]
92 92
93 93 if fileid:
94 94 params['anchor'] = 'a_' + fileid
95 95 return h.link_to(label, h.url.current(**params), title=tooltiplbl, class_='tooltip')
96 96
97 97
98 98 def get_line_ctx(fid, GET):
99 99 ln_ctx_global = GET.get('context')
100 100 if fid:
101 101 ln_ctx = filter(lambda k: k.startswith('C'), GET.getall(fid))
102 102 else:
103 103 _ln_ctx = filter(lambda k: k.startswith('C'), GET)
104 104 ln_ctx = GET.get(_ln_ctx[0]) if _ln_ctx else ln_ctx_global
105 105 if ln_ctx:
106 106 ln_ctx = [ln_ctx]
107 107
108 108 if ln_ctx:
109 109 retval = ln_ctx[0].split(':')[-1]
110 110 else:
111 111 retval = ln_ctx_global
112 112
113 113 try:
114 114 return int(retval)
115 115 except Exception:
116 116 return 3
117 117
118 118
119 119 def _context_url(GET, fileid=None):
120 120 """
121 121 Generates a url for context lines.
122 122
123 123 :param fileid:
124 124 """
125 125
126 126 fileid = str(fileid) if fileid else None
127 127 ig_ws = get_ignore_ws(fileid, GET)
128 128 ln_ctx = (get_line_ctx(fileid, GET) or 3) * 2
129 129
130 130 params = defaultdict(list)
131 131 _update_with_GET(params, GET)
132 132
133 133 if ln_ctx > 0:
134 134 params['context'] += [ln_ctx]
135 135
136 136 if ig_ws:
137 137 ig_ws_key = 'ignorews'
138 138 ig_ws_val = 1
139 139 params[ig_ws_key] += [ig_ws_val]
140 140
141 141 lbl = _('Increase context')
142 142 tooltiplbl = _('Increase context for all diffs')
143 143
144 144 if fileid:
145 145 params['anchor'] = 'a_' + fileid
146 146 return h.link_to(lbl, h.url.current(**params), title=tooltiplbl, class_='tooltip')
147 147
148 148
149 149 class ChangesetController(BaseRepoController):
150 150
151 151 def __before__(self):
152 152 super(ChangesetController, self).__before__()
153 153 c.affected_files_cut_off = 60
154 154
155 155 def _index(self, commit_id_range, method):
156 156 c.ignorews_url = _ignorews_url
157 157 c.context_url = _context_url
158 158 c.fulldiff = fulldiff = request.GET.get('fulldiff')
159 159
160 160 # fetch global flags of ignore ws or context lines
161 161 context_lcl = get_line_ctx('', request.GET)
162 162 ign_whitespace_lcl = get_ignore_ws('', request.GET)
163 163
164 164 # diff_limit will cut off the whole diff if the limit is applied
165 165 # otherwise it will just hide the big files from the front-end
166 166 diff_limit = self.cut_off_limit_diff
167 167 file_limit = self.cut_off_limit_file
168 168
169 169 # get ranges of commit ids if preset
170 170 commit_range = commit_id_range.split('...')[:2]
171 171
172 172 try:
173 173 pre_load = ['affected_files', 'author', 'branch', 'date',
174 174 'message', 'parents']
175 175
176 176 if len(commit_range) == 2:
177 177 commits = c.rhodecode_repo.get_commits(
178 178 start_id=commit_range[0], end_id=commit_range[1],
179 179 pre_load=pre_load)
180 180 commits = list(commits)
181 181 else:
182 182 commits = [c.rhodecode_repo.get_commit(
183 183 commit_id=commit_id_range, pre_load=pre_load)]
184 184
185 185 c.commit_ranges = commits
186 186 if not c.commit_ranges:
187 187 raise RepositoryError(
188 188 'The commit range returned an empty result')
189 189 except CommitDoesNotExistError:
190 190 msg = _('No such commit exists for this repository')
191 191 h.flash(msg, category='error')
192 192 raise HTTPNotFound()
193 193 except Exception:
194 194 log.exception("General failure")
195 195 raise HTTPNotFound()
196 196
197 197 c.changes = OrderedDict()
198 198 c.lines_added = 0
199 199 c.lines_deleted = 0
200 200
201 201 # auto collapse if we have more than limit
202 202 collapse_limit = diffs.DiffProcessor._collapse_commits_over
203 203 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
204 204
205 205 c.commit_statuses = ChangesetStatus.STATUSES
206 206 c.inline_comments = []
207 207 c.files = []
208 208
209 209 c.statuses = []
210 210 c.comments = []
211 211 if len(c.commit_ranges) == 1:
212 212 commit = c.commit_ranges[0]
213 c.comments = ChangesetCommentsModel().get_comments(
213 c.comments = CommentsModel().get_comments(
214 214 c.rhodecode_db_repo.repo_id,
215 215 revision=commit.raw_id)
216 216 c.statuses.append(ChangesetStatusModel().get_status(
217 217 c.rhodecode_db_repo.repo_id, commit.raw_id))
218 218 # comments from PR
219 219 statuses = ChangesetStatusModel().get_statuses(
220 220 c.rhodecode_db_repo.repo_id, commit.raw_id,
221 221 with_revisions=True)
222 222 prs = set(st.pull_request for st in statuses
223 223 if st.pull_request is not None)
224 224 # from associated statuses, check the pull requests, and
225 225 # show comments from them
226 226 for pr in prs:
227 227 c.comments.extend(pr.comments)
228 228
229 229 # Iterate over ranges (default commit view is always one commit)
230 230 for commit in c.commit_ranges:
231 231 c.changes[commit.raw_id] = []
232 232
233 233 commit2 = commit
234 234 commit1 = commit.parents[0] if commit.parents else EmptyCommit()
235 235
236 236 _diff = c.rhodecode_repo.get_diff(
237 237 commit1, commit2,
238 238 ignore_whitespace=ign_whitespace_lcl, context=context_lcl)
239 239 diff_processor = diffs.DiffProcessor(
240 240 _diff, format='newdiff', diff_limit=diff_limit,
241 241 file_limit=file_limit, show_full_diff=fulldiff)
242 242
243 243 commit_changes = OrderedDict()
244 244 if method == 'show':
245 245 _parsed = diff_processor.prepare()
246 246 c.limited_diff = isinstance(_parsed, diffs.LimitedDiffContainer)
247 247
248 248 _parsed = diff_processor.prepare()
249 249
250 250 def _node_getter(commit):
251 251 def get_node(fname):
252 252 try:
253 253 return commit.get_node(fname)
254 254 except NodeDoesNotExistError:
255 255 return None
256 256 return get_node
257 257
258 inline_comments = ChangesetCommentsModel().get_inline_comments(
258 inline_comments = CommentsModel().get_inline_comments(
259 259 c.rhodecode_db_repo.repo_id, revision=commit.raw_id)
260 c.inline_cnt = ChangesetCommentsModel().get_inline_comments_count(
260 c.inline_cnt = CommentsModel().get_inline_comments_count(
261 261 inline_comments)
262 262
263 263 diffset = codeblocks.DiffSet(
264 264 repo_name=c.repo_name,
265 265 source_node_getter=_node_getter(commit1),
266 266 target_node_getter=_node_getter(commit2),
267 267 comments=inline_comments
268 268 ).render_patchset(_parsed, commit1.raw_id, commit2.raw_id)
269 269 c.changes[commit.raw_id] = diffset
270 270 else:
271 271 # downloads/raw we only need RAW diff nothing else
272 272 diff = diff_processor.as_raw()
273 273 c.changes[commit.raw_id] = [None, None, None, None, diff, None, None]
274 274
275 275 # sort comments by how they were generated
276 276 c.comments = sorted(c.comments, key=lambda x: x.comment_id)
277 277
278 278
279 279 if len(c.commit_ranges) == 1:
280 280 c.commit = c.commit_ranges[0]
281 281 c.parent_tmpl = ''.join(
282 282 '# Parent %s\n' % x.raw_id for x in c.commit.parents)
283 283 if method == 'download':
284 284 response.content_type = 'text/plain'
285 285 response.content_disposition = (
286 286 'attachment; filename=%s.diff' % commit_id_range[:12])
287 287 return diff
288 288 elif method == 'patch':
289 289 response.content_type = 'text/plain'
290 290 c.diff = safe_unicode(diff)
291 291 return render('changeset/patch_changeset.mako')
292 292 elif method == 'raw':
293 293 response.content_type = 'text/plain'
294 294 return diff
295 295 elif method == 'show':
296 296 if len(c.commit_ranges) == 1:
297 297 return render('changeset/changeset.mako')
298 298 else:
299 299 c.ancestor = None
300 300 c.target_repo = c.rhodecode_db_repo
301 301 return render('changeset/changeset_range.mako')
302 302
303 303 @LoginRequired()
304 304 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
305 305 'repository.admin')
306 306 def index(self, revision, method='show'):
307 307 return self._index(revision, method=method)
308 308
309 309 @LoginRequired()
310 310 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
311 311 'repository.admin')
312 312 def changeset_raw(self, revision):
313 313 return self._index(revision, method='raw')
314 314
315 315 @LoginRequired()
316 316 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
317 317 'repository.admin')
318 318 def changeset_patch(self, revision):
319 319 return self._index(revision, method='patch')
320 320
321 321 @LoginRequired()
322 322 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
323 323 'repository.admin')
324 324 def changeset_download(self, revision):
325 325 return self._index(revision, method='download')
326 326
327 327 @LoginRequired()
328 328 @NotAnonymous()
329 329 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
330 330 'repository.admin')
331 331 @auth.CSRFRequired()
332 332 @jsonify
333 333 def comment(self, repo_name, revision):
334 334 commit_id = revision
335 335 status = request.POST.get('changeset_status', None)
336 336 text = request.POST.get('text')
337 337 if status:
338 338 text = text or (_('Status change %(transition_icon)s %(status)s')
339 339 % {'transition_icon': '>',
340 340 'status': ChangesetStatus.get_status_lbl(status)})
341 341
342 342 multi_commit_ids = filter(
343 343 lambda s: s not in ['', None],
344 344 request.POST.get('commit_ids', '').split(','),)
345 345
346 346 commit_ids = multi_commit_ids or [commit_id]
347 347 comment = None
348 348 for current_id in filter(None, commit_ids):
349 c.co = comment = ChangesetCommentsModel().create(
349 c.co = comment = CommentsModel().create(
350 350 text=text,
351 351 repo=c.rhodecode_db_repo.repo_id,
352 352 user=c.rhodecode_user.user_id,
353 353 commit_id=current_id,
354 354 f_path=request.POST.get('f_path'),
355 355 line_no=request.POST.get('line'),
356 356 status_change=(ChangesetStatus.get_status_lbl(status)
357 357 if status else None),
358 358 status_change_type=status
359 359 )
360 360 c.inline_comment = True if comment.line_no else False
361 361
362 362 # get status if set !
363 363 if status:
364 364 # if latest status was from pull request and it's closed
365 365 # disallow changing status !
366 366 # dont_allow_on_closed_pull_request = True !
367 367
368 368 try:
369 369 ChangesetStatusModel().set_status(
370 370 c.rhodecode_db_repo.repo_id,
371 371 status,
372 372 c.rhodecode_user.user_id,
373 373 comment,
374 374 revision=current_id,
375 375 dont_allow_on_closed_pull_request=True
376 376 )
377 377 except StatusChangeOnClosedPullRequestError:
378 378 msg = _('Changing the status of a commit associated with '
379 379 'a closed pull request is not allowed')
380 380 log.exception(msg)
381 381 h.flash(msg, category='warning')
382 382 return redirect(h.url(
383 383 'changeset_home', repo_name=repo_name,
384 384 revision=current_id))
385 385
386 386 # finalize, commit and redirect
387 387 Session().commit()
388 388
389 389 data = {
390 390 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
391 391 }
392 392 if comment:
393 393 data.update(comment.get_dict())
394 394 data.update({'rendered_text':
395 395 render('changeset/changeset_comment_block.mako')})
396 396
397 397 return data
398 398
399 399 @LoginRequired()
400 400 @NotAnonymous()
401 401 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
402 402 'repository.admin')
403 403 @auth.CSRFRequired()
404 404 def preview_comment(self):
405 405 # Technically a CSRF token is not needed as no state changes with this
406 406 # call. However, as this is a POST is better to have it, so automated
407 407 # tools don't flag it as potential CSRF.
408 408 # Post is required because the payload could be bigger than the maximum
409 409 # allowed by GET.
410 410 if not request.environ.get('HTTP_X_PARTIAL_XHR'):
411 411 raise HTTPBadRequest()
412 412 text = request.POST.get('text')
413 413 renderer = request.POST.get('renderer') or 'rst'
414 414 if text:
415 415 return h.render(text, renderer=renderer, mentions=True)
416 416 return ''
417 417
418 418 @LoginRequired()
419 419 @NotAnonymous()
420 420 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
421 421 'repository.admin')
422 422 @auth.CSRFRequired()
423 423 @jsonify
424 424 def delete_comment(self, repo_name, comment_id):
425 425 comment = ChangesetComment.get(comment_id)
426 426 owner = (comment.author.user_id == c.rhodecode_user.user_id)
427 427 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
428 428 if h.HasPermissionAny('hg.admin')() or is_repo_admin or owner:
429 ChangesetCommentsModel().delete(comment=comment)
429 CommentsModel().delete(comment=comment)
430 430 Session().commit()
431 431 return True
432 432 else:
433 433 raise HTTPForbidden()
434 434
435 435 @LoginRequired()
436 436 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
437 437 'repository.admin')
438 438 @jsonify
439 439 def changeset_info(self, repo_name, revision):
440 440 if request.is_xhr:
441 441 try:
442 442 return c.rhodecode_repo.get_commit(commit_id=revision)
443 443 except CommitDoesNotExistError as e:
444 444 return EmptyCommit(message=str(e))
445 445 else:
446 446 raise HTTPBadRequest()
447 447
448 448 @LoginRequired()
449 449 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
450 450 'repository.admin')
451 451 @jsonify
452 452 def changeset_children(self, repo_name, revision):
453 453 if request.is_xhr:
454 454 commit = c.rhodecode_repo.get_commit(commit_id=revision)
455 455 result = {"results": commit.children}
456 456 return result
457 457 else:
458 458 raise HTTPBadRequest()
459 459
460 460 @LoginRequired()
461 461 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
462 462 'repository.admin')
463 463 @jsonify
464 464 def changeset_parents(self, repo_name, revision):
465 465 if request.is_xhr:
466 466 commit = c.rhodecode_repo.get_commit(commit_id=revision)
467 467 result = {"results": commit.parents}
468 468 return result
469 469 else:
470 470 raise HTTPBadRequest()
@@ -1,1024 +1,1024 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2017 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 pull requests controller for rhodecode for initializing pull requests
23 23 """
24 24 import types
25 25
26 26 import peppercorn
27 27 import formencode
28 28 import logging
29 29 import collections
30 30
31 31 from webob.exc import HTTPNotFound, HTTPForbidden, HTTPBadRequest
32 32 from pylons import request, tmpl_context as c, url
33 33 from pylons.controllers.util import redirect
34 34 from pylons.i18n.translation import _
35 35 from pyramid.threadlocal import get_current_registry
36 36 from sqlalchemy.sql import func
37 37 from sqlalchemy.sql.expression import or_
38 38
39 39 from rhodecode import events
40 40 from rhodecode.lib import auth, diffs, helpers as h, codeblocks
41 41 from rhodecode.lib.ext_json import json
42 42 from rhodecode.lib.base import (
43 43 BaseRepoController, render, vcs_operation_context)
44 44 from rhodecode.lib.auth import (
45 45 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous,
46 46 HasAcceptedRepoType, XHRRequired)
47 47 from rhodecode.lib.channelstream import channelstream_request
48 48 from rhodecode.lib.utils import jsonify
49 49 from rhodecode.lib.utils2 import (
50 50 safe_int, safe_str, str2bool, safe_unicode)
51 51 from rhodecode.lib.vcs.backends.base import (
52 52 EmptyCommit, UpdateFailureReason, EmptyRepository)
53 53 from rhodecode.lib.vcs.exceptions import (
54 54 EmptyRepositoryError, CommitDoesNotExistError, RepositoryRequirementError,
55 55 NodeDoesNotExistError)
56 56
57 57 from rhodecode.model.changeset_status import ChangesetStatusModel
58 from rhodecode.model.comment import ChangesetCommentsModel
58 from rhodecode.model.comment import CommentsModel
59 59 from rhodecode.model.db import (PullRequest, ChangesetStatus, ChangesetComment,
60 60 Repository, PullRequestVersion)
61 61 from rhodecode.model.forms import PullRequestForm
62 62 from rhodecode.model.meta import Session
63 63 from rhodecode.model.pull_request import PullRequestModel
64 64
65 65 log = logging.getLogger(__name__)
66 66
67 67
68 68 class PullrequestsController(BaseRepoController):
69 69 def __before__(self):
70 70 super(PullrequestsController, self).__before__()
71 71
72 72 def _load_compare_data(self, pull_request, inline_comments):
73 73 """
74 74 Load context data needed for generating compare diff
75 75
76 76 :param pull_request: object related to the request
77 77 :param enable_comments: flag to determine if comments are included
78 78 """
79 79 source_repo = pull_request.source_repo
80 80 source_ref_id = pull_request.source_ref_parts.commit_id
81 81
82 82 target_repo = pull_request.target_repo
83 83 target_ref_id = pull_request.target_ref_parts.commit_id
84 84
85 85 # despite opening commits for bookmarks/branches/tags, we always
86 86 # convert this to rev to prevent changes after bookmark or branch change
87 87 c.source_ref_type = 'rev'
88 88 c.source_ref = source_ref_id
89 89
90 90 c.target_ref_type = 'rev'
91 91 c.target_ref = target_ref_id
92 92
93 93 c.source_repo = source_repo
94 94 c.target_repo = target_repo
95 95
96 96 c.fulldiff = bool(request.GET.get('fulldiff'))
97 97
98 98 # diff_limit is the old behavior, will cut off the whole diff
99 99 # if the limit is applied otherwise will just hide the
100 100 # big files from the front-end
101 101 diff_limit = self.cut_off_limit_diff
102 102 file_limit = self.cut_off_limit_file
103 103
104 104 pre_load = ["author", "branch", "date", "message"]
105 105
106 106 c.commit_ranges = []
107 107 source_commit = EmptyCommit()
108 108 target_commit = EmptyCommit()
109 109 c.missing_requirements = False
110 110 try:
111 111 c.commit_ranges = [
112 112 source_repo.get_commit(commit_id=rev, pre_load=pre_load)
113 113 for rev in pull_request.revisions]
114 114
115 115 c.statuses = source_repo.statuses(
116 116 [x.raw_id for x in c.commit_ranges])
117 117
118 118 target_commit = source_repo.get_commit(
119 119 commit_id=safe_str(target_ref_id))
120 120 source_commit = source_repo.get_commit(
121 121 commit_id=safe_str(source_ref_id))
122 122 except RepositoryRequirementError:
123 123 c.missing_requirements = True
124 124
125 125 # auto collapse if we have more than limit
126 126 collapse_limit = diffs.DiffProcessor._collapse_commits_over
127 127 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
128 128
129 129 c.changes = {}
130 130 c.missing_commits = False
131 131 if (c.missing_requirements or
132 132 isinstance(source_commit, EmptyCommit) or
133 133 source_commit == target_commit):
134 134 _parsed = []
135 135 c.missing_commits = True
136 136 else:
137 137 vcs_diff = PullRequestModel().get_diff(pull_request)
138 138 diff_processor = diffs.DiffProcessor(
139 139 vcs_diff, format='newdiff', diff_limit=diff_limit,
140 140 file_limit=file_limit, show_full_diff=c.fulldiff)
141 141
142 142 _parsed = diff_processor.prepare()
143 143 c.limited_diff = isinstance(_parsed, diffs.LimitedDiffContainer)
144 144
145 145 included_files = {}
146 146 for f in _parsed:
147 147 included_files[f['filename']] = f['stats']
148 148
149 149 c.deleted_files = [fname for fname in inline_comments if
150 150 fname not in included_files]
151 151
152 152 c.deleted_files_comments = collections.defaultdict(dict)
153 153 for fname, per_line_comments in inline_comments.items():
154 154 if fname in c.deleted_files:
155 155 c.deleted_files_comments[fname]['stats'] = 0
156 156 c.deleted_files_comments[fname]['comments'] = list()
157 157 for lno, comments in per_line_comments.items():
158 158 c.deleted_files_comments[fname]['comments'].extend(comments)
159 159
160 160 def _node_getter(commit):
161 161 def get_node(fname):
162 162 try:
163 163 return commit.get_node(fname)
164 164 except NodeDoesNotExistError:
165 165 return None
166 166 return get_node
167 167
168 168 c.diffset = codeblocks.DiffSet(
169 169 repo_name=c.repo_name,
170 170 source_repo_name=c.source_repo.repo_name,
171 171 source_node_getter=_node_getter(target_commit),
172 172 target_node_getter=_node_getter(source_commit),
173 173 comments=inline_comments
174 174 ).render_patchset(_parsed, target_commit.raw_id, source_commit.raw_id)
175 175
176 176 def _extract_ordering(self, request):
177 177 column_index = safe_int(request.GET.get('order[0][column]'))
178 178 order_dir = request.GET.get('order[0][dir]', 'desc')
179 179 order_by = request.GET.get(
180 180 'columns[%s][data][sort]' % column_index, 'name_raw')
181 181 return order_by, order_dir
182 182
183 183 @LoginRequired()
184 184 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
185 185 'repository.admin')
186 186 @HasAcceptedRepoType('git', 'hg')
187 187 def show_all(self, repo_name):
188 188 # filter types
189 189 c.active = 'open'
190 190 c.source = str2bool(request.GET.get('source'))
191 191 c.closed = str2bool(request.GET.get('closed'))
192 192 c.my = str2bool(request.GET.get('my'))
193 193 c.awaiting_review = str2bool(request.GET.get('awaiting_review'))
194 194 c.awaiting_my_review = str2bool(request.GET.get('awaiting_my_review'))
195 195 c.repo_name = repo_name
196 196
197 197 opened_by = None
198 198 if c.my:
199 199 c.active = 'my'
200 200 opened_by = [c.rhodecode_user.user_id]
201 201
202 202 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
203 203 if c.closed:
204 204 c.active = 'closed'
205 205 statuses = [PullRequest.STATUS_CLOSED]
206 206
207 207 if c.awaiting_review and not c.source:
208 208 c.active = 'awaiting'
209 209 if c.source and not c.awaiting_review:
210 210 c.active = 'source'
211 211 if c.awaiting_my_review:
212 212 c.active = 'awaiting_my'
213 213
214 214 data = self._get_pull_requests_list(
215 215 repo_name=repo_name, opened_by=opened_by, statuses=statuses)
216 216 if not request.is_xhr:
217 217 c.data = json.dumps(data['data'])
218 218 c.records_total = data['recordsTotal']
219 219 return render('/pullrequests/pullrequests.mako')
220 220 else:
221 221 return json.dumps(data)
222 222
223 223 def _get_pull_requests_list(self, repo_name, opened_by, statuses):
224 224 # pagination
225 225 start = safe_int(request.GET.get('start'), 0)
226 226 length = safe_int(request.GET.get('length'), c.visual.dashboard_items)
227 227 order_by, order_dir = self._extract_ordering(request)
228 228
229 229 if c.awaiting_review:
230 230 pull_requests = PullRequestModel().get_awaiting_review(
231 231 repo_name, source=c.source, opened_by=opened_by,
232 232 statuses=statuses, offset=start, length=length,
233 233 order_by=order_by, order_dir=order_dir)
234 234 pull_requests_total_count = PullRequestModel(
235 235 ).count_awaiting_review(
236 236 repo_name, source=c.source, statuses=statuses,
237 237 opened_by=opened_by)
238 238 elif c.awaiting_my_review:
239 239 pull_requests = PullRequestModel().get_awaiting_my_review(
240 240 repo_name, source=c.source, opened_by=opened_by,
241 241 user_id=c.rhodecode_user.user_id, statuses=statuses,
242 242 offset=start, length=length, order_by=order_by,
243 243 order_dir=order_dir)
244 244 pull_requests_total_count = PullRequestModel(
245 245 ).count_awaiting_my_review(
246 246 repo_name, source=c.source, user_id=c.rhodecode_user.user_id,
247 247 statuses=statuses, opened_by=opened_by)
248 248 else:
249 249 pull_requests = PullRequestModel().get_all(
250 250 repo_name, source=c.source, opened_by=opened_by,
251 251 statuses=statuses, offset=start, length=length,
252 252 order_by=order_by, order_dir=order_dir)
253 253 pull_requests_total_count = PullRequestModel().count_all(
254 254 repo_name, source=c.source, statuses=statuses,
255 255 opened_by=opened_by)
256 256
257 257 from rhodecode.lib.utils import PartialRenderer
258 258 _render = PartialRenderer('data_table/_dt_elements.mako')
259 259 data = []
260 260 for pr in pull_requests:
261 comments = ChangesetCommentsModel().get_all_comments(
261 comments = CommentsModel().get_all_comments(
262 262 c.rhodecode_db_repo.repo_id, pull_request=pr)
263 263
264 264 data.append({
265 265 'name': _render('pullrequest_name',
266 266 pr.pull_request_id, pr.target_repo.repo_name),
267 267 'name_raw': pr.pull_request_id,
268 268 'status': _render('pullrequest_status',
269 269 pr.calculated_review_status()),
270 270 'title': _render(
271 271 'pullrequest_title', pr.title, pr.description),
272 272 'description': h.escape(pr.description),
273 273 'updated_on': _render('pullrequest_updated_on',
274 274 h.datetime_to_time(pr.updated_on)),
275 275 'updated_on_raw': h.datetime_to_time(pr.updated_on),
276 276 'created_on': _render('pullrequest_updated_on',
277 277 h.datetime_to_time(pr.created_on)),
278 278 'created_on_raw': h.datetime_to_time(pr.created_on),
279 279 'author': _render('pullrequest_author',
280 280 pr.author.full_contact, ),
281 281 'author_raw': pr.author.full_name,
282 282 'comments': _render('pullrequest_comments', len(comments)),
283 283 'comments_raw': len(comments),
284 284 'closed': pr.is_closed(),
285 285 })
286 286 # json used to render the grid
287 287 data = ({
288 288 'data': data,
289 289 'recordsTotal': pull_requests_total_count,
290 290 'recordsFiltered': pull_requests_total_count,
291 291 })
292 292 return data
293 293
294 294 @LoginRequired()
295 295 @NotAnonymous()
296 296 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
297 297 'repository.admin')
298 298 @HasAcceptedRepoType('git', 'hg')
299 299 def index(self):
300 300 source_repo = c.rhodecode_db_repo
301 301
302 302 try:
303 303 source_repo.scm_instance().get_commit()
304 304 except EmptyRepositoryError:
305 305 h.flash(h.literal(_('There are no commits yet')),
306 306 category='warning')
307 307 redirect(url('summary_home', repo_name=source_repo.repo_name))
308 308
309 309 commit_id = request.GET.get('commit')
310 310 branch_ref = request.GET.get('branch')
311 311 bookmark_ref = request.GET.get('bookmark')
312 312
313 313 try:
314 314 source_repo_data = PullRequestModel().generate_repo_data(
315 315 source_repo, commit_id=commit_id,
316 316 branch=branch_ref, bookmark=bookmark_ref)
317 317 except CommitDoesNotExistError as e:
318 318 log.exception(e)
319 319 h.flash(_('Commit does not exist'), 'error')
320 320 redirect(url('pullrequest_home', repo_name=source_repo.repo_name))
321 321
322 322 default_target_repo = source_repo
323 323
324 324 if source_repo.parent:
325 325 parent_vcs_obj = source_repo.parent.scm_instance()
326 326 if parent_vcs_obj and not parent_vcs_obj.is_empty():
327 327 # change default if we have a parent repo
328 328 default_target_repo = source_repo.parent
329 329
330 330 target_repo_data = PullRequestModel().generate_repo_data(
331 331 default_target_repo)
332 332
333 333 selected_source_ref = source_repo_data['refs']['selected_ref']
334 334
335 335 title_source_ref = selected_source_ref.split(':', 2)[1]
336 336 c.default_title = PullRequestModel().generate_pullrequest_title(
337 337 source=source_repo.repo_name,
338 338 source_ref=title_source_ref,
339 339 target=default_target_repo.repo_name
340 340 )
341 341
342 342 c.default_repo_data = {
343 343 'source_repo_name': source_repo.repo_name,
344 344 'source_refs_json': json.dumps(source_repo_data),
345 345 'target_repo_name': default_target_repo.repo_name,
346 346 'target_refs_json': json.dumps(target_repo_data),
347 347 }
348 348 c.default_source_ref = selected_source_ref
349 349
350 350 return render('/pullrequests/pullrequest.mako')
351 351
352 352 @LoginRequired()
353 353 @NotAnonymous()
354 354 @XHRRequired()
355 355 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
356 356 'repository.admin')
357 357 @jsonify
358 358 def get_repo_refs(self, repo_name, target_repo_name):
359 359 repo = Repository.get_by_repo_name(target_repo_name)
360 360 if not repo:
361 361 raise HTTPNotFound
362 362 return PullRequestModel().generate_repo_data(repo)
363 363
364 364 @LoginRequired()
365 365 @NotAnonymous()
366 366 @XHRRequired()
367 367 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
368 368 'repository.admin')
369 369 @jsonify
370 370 def get_repo_destinations(self, repo_name):
371 371 repo = Repository.get_by_repo_name(repo_name)
372 372 if not repo:
373 373 raise HTTPNotFound
374 374 filter_query = request.GET.get('query')
375 375
376 376 query = Repository.query() \
377 377 .order_by(func.length(Repository.repo_name)) \
378 378 .filter(or_(
379 379 Repository.repo_name == repo.repo_name,
380 380 Repository.fork_id == repo.repo_id))
381 381
382 382 if filter_query:
383 383 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
384 384 query = query.filter(
385 385 Repository.repo_name.ilike(ilike_expression))
386 386
387 387 add_parent = False
388 388 if repo.parent:
389 389 if filter_query in repo.parent.repo_name:
390 390 parent_vcs_obj = repo.parent.scm_instance()
391 391 if parent_vcs_obj and not parent_vcs_obj.is_empty():
392 392 add_parent = True
393 393
394 394 limit = 20 - 1 if add_parent else 20
395 395 all_repos = query.limit(limit).all()
396 396 if add_parent:
397 397 all_repos += [repo.parent]
398 398
399 399 repos = []
400 400 for obj in self.scm_model.get_repos(all_repos):
401 401 repos.append({
402 402 'id': obj['name'],
403 403 'text': obj['name'],
404 404 'type': 'repo',
405 405 'obj': obj['dbrepo']
406 406 })
407 407
408 408 data = {
409 409 'more': False,
410 410 'results': [{
411 411 'text': _('Repositories'),
412 412 'children': repos
413 413 }] if repos else []
414 414 }
415 415 return data
416 416
417 417 @LoginRequired()
418 418 @NotAnonymous()
419 419 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
420 420 'repository.admin')
421 421 @HasAcceptedRepoType('git', 'hg')
422 422 @auth.CSRFRequired()
423 423 def create(self, repo_name):
424 424 repo = Repository.get_by_repo_name(repo_name)
425 425 if not repo:
426 426 raise HTTPNotFound
427 427
428 428 controls = peppercorn.parse(request.POST.items())
429 429
430 430 try:
431 431 _form = PullRequestForm(repo.repo_id)().to_python(controls)
432 432 except formencode.Invalid as errors:
433 433 if errors.error_dict.get('revisions'):
434 434 msg = 'Revisions: %s' % errors.error_dict['revisions']
435 435 elif errors.error_dict.get('pullrequest_title'):
436 436 msg = _('Pull request requires a title with min. 3 chars')
437 437 else:
438 438 msg = _('Error creating pull request: {}').format(errors)
439 439 log.exception(msg)
440 440 h.flash(msg, 'error')
441 441
442 442 # would rather just go back to form ...
443 443 return redirect(url('pullrequest_home', repo_name=repo_name))
444 444
445 445 source_repo = _form['source_repo']
446 446 source_ref = _form['source_ref']
447 447 target_repo = _form['target_repo']
448 448 target_ref = _form['target_ref']
449 449 commit_ids = _form['revisions'][::-1]
450 450 reviewers = [
451 451 (r['user_id'], r['reasons']) for r in _form['review_members']]
452 452
453 453 # find the ancestor for this pr
454 454 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
455 455 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
456 456
457 457 source_scm = source_db_repo.scm_instance()
458 458 target_scm = target_db_repo.scm_instance()
459 459
460 460 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
461 461 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
462 462
463 463 ancestor = source_scm.get_common_ancestor(
464 464 source_commit.raw_id, target_commit.raw_id, target_scm)
465 465
466 466 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
467 467 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
468 468
469 469 pullrequest_title = _form['pullrequest_title']
470 470 title_source_ref = source_ref.split(':', 2)[1]
471 471 if not pullrequest_title:
472 472 pullrequest_title = PullRequestModel().generate_pullrequest_title(
473 473 source=source_repo,
474 474 source_ref=title_source_ref,
475 475 target=target_repo
476 476 )
477 477
478 478 description = _form['pullrequest_desc']
479 479 try:
480 480 pull_request = PullRequestModel().create(
481 481 c.rhodecode_user.user_id, source_repo, source_ref, target_repo,
482 482 target_ref, commit_ids, reviewers, pullrequest_title,
483 483 description
484 484 )
485 485 Session().commit()
486 486 h.flash(_('Successfully opened new pull request'),
487 487 category='success')
488 488 except Exception as e:
489 489 msg = _('Error occurred during sending pull request')
490 490 log.exception(msg)
491 491 h.flash(msg, category='error')
492 492 return redirect(url('pullrequest_home', repo_name=repo_name))
493 493
494 494 return redirect(url('pullrequest_show', repo_name=target_repo,
495 495 pull_request_id=pull_request.pull_request_id))
496 496
497 497 @LoginRequired()
498 498 @NotAnonymous()
499 499 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
500 500 'repository.admin')
501 501 @auth.CSRFRequired()
502 502 @jsonify
503 503 def update(self, repo_name, pull_request_id):
504 504 pull_request_id = safe_int(pull_request_id)
505 505 pull_request = PullRequest.get_or_404(pull_request_id)
506 506 # only owner or admin can update it
507 507 allowed_to_update = PullRequestModel().check_user_update(
508 508 pull_request, c.rhodecode_user)
509 509 if allowed_to_update:
510 510 controls = peppercorn.parse(request.POST.items())
511 511
512 512 if 'review_members' in controls:
513 513 self._update_reviewers(
514 514 pull_request_id, controls['review_members'])
515 515 elif str2bool(request.POST.get('update_commits', 'false')):
516 516 self._update_commits(pull_request)
517 517 elif str2bool(request.POST.get('close_pull_request', 'false')):
518 518 self._reject_close(pull_request)
519 519 elif str2bool(request.POST.get('edit_pull_request', 'false')):
520 520 self._edit_pull_request(pull_request)
521 521 else:
522 522 raise HTTPBadRequest()
523 523 return True
524 524 raise HTTPForbidden()
525 525
526 526 def _edit_pull_request(self, pull_request):
527 527 try:
528 528 PullRequestModel().edit(
529 529 pull_request, request.POST.get('title'),
530 530 request.POST.get('description'))
531 531 except ValueError:
532 532 msg = _(u'Cannot update closed pull requests.')
533 533 h.flash(msg, category='error')
534 534 return
535 535 else:
536 536 Session().commit()
537 537
538 538 msg = _(u'Pull request title & description updated.')
539 539 h.flash(msg, category='success')
540 540 return
541 541
542 542 def _update_commits(self, pull_request):
543 543 resp = PullRequestModel().update_commits(pull_request)
544 544
545 545 if resp.executed:
546 546 msg = _(
547 547 u'Pull request updated to "{source_commit_id}" with '
548 548 u'{count_added} added, {count_removed} removed commits.')
549 549 msg = msg.format(
550 550 source_commit_id=pull_request.source_ref_parts.commit_id,
551 551 count_added=len(resp.changes.added),
552 552 count_removed=len(resp.changes.removed))
553 553 h.flash(msg, category='success')
554 554
555 555 registry = get_current_registry()
556 556 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
557 557 channelstream_config = rhodecode_plugins.get('channelstream', {})
558 558 if channelstream_config.get('enabled'):
559 559 message = msg + (
560 560 ' - <a onclick="window.location.reload()">'
561 561 '<strong>{}</strong></a>'.format(_('Reload page')))
562 562 channel = '/repo${}$/pr/{}'.format(
563 563 pull_request.target_repo.repo_name,
564 564 pull_request.pull_request_id
565 565 )
566 566 payload = {
567 567 'type': 'message',
568 568 'user': 'system',
569 569 'exclude_users': [request.user.username],
570 570 'channel': channel,
571 571 'message': {
572 572 'message': message,
573 573 'level': 'success',
574 574 'topic': '/notifications'
575 575 }
576 576 }
577 577 channelstream_request(
578 578 channelstream_config, [payload], '/message',
579 579 raise_exc=False)
580 580 else:
581 581 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
582 582 warning_reasons = [
583 583 UpdateFailureReason.NO_CHANGE,
584 584 UpdateFailureReason.WRONG_REF_TPYE,
585 585 ]
586 586 category = 'warning' if resp.reason in warning_reasons else 'error'
587 587 h.flash(msg, category=category)
588 588
589 589 @auth.CSRFRequired()
590 590 @LoginRequired()
591 591 @NotAnonymous()
592 592 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
593 593 'repository.admin')
594 594 def merge(self, repo_name, pull_request_id):
595 595 """
596 596 POST /{repo_name}/pull-request/{pull_request_id}
597 597
598 598 Merge will perform a server-side merge of the specified
599 599 pull request, if the pull request is approved and mergeable.
600 600 After succesfull merging, the pull request is automatically
601 601 closed, with a relevant comment.
602 602 """
603 603 pull_request_id = safe_int(pull_request_id)
604 604 pull_request = PullRequest.get_or_404(pull_request_id)
605 605 user = c.rhodecode_user
606 606
607 607 if self._meets_merge_pre_conditions(pull_request, user):
608 608 log.debug("Pre-conditions checked, trying to merge.")
609 609 extras = vcs_operation_context(
610 610 request.environ, repo_name=pull_request.target_repo.repo_name,
611 611 username=user.username, action='push',
612 612 scm=pull_request.target_repo.repo_type)
613 613 self._merge_pull_request(pull_request, user, extras)
614 614
615 615 return redirect(url(
616 616 'pullrequest_show',
617 617 repo_name=pull_request.target_repo.repo_name,
618 618 pull_request_id=pull_request.pull_request_id))
619 619
620 620 def _meets_merge_pre_conditions(self, pull_request, user):
621 621 if not PullRequestModel().check_user_merge(pull_request, user):
622 622 raise HTTPForbidden()
623 623
624 624 merge_status, msg = PullRequestModel().merge_status(pull_request)
625 625 if not merge_status:
626 626 log.debug("Cannot merge, not mergeable.")
627 627 h.flash(msg, category='error')
628 628 return False
629 629
630 630 if (pull_request.calculated_review_status()
631 631 is not ChangesetStatus.STATUS_APPROVED):
632 632 log.debug("Cannot merge, approval is pending.")
633 633 msg = _('Pull request reviewer approval is pending.')
634 634 h.flash(msg, category='error')
635 635 return False
636 636 return True
637 637
638 638 def _merge_pull_request(self, pull_request, user, extras):
639 639 merge_resp = PullRequestModel().merge(
640 640 pull_request, user, extras=extras)
641 641
642 642 if merge_resp.executed:
643 643 log.debug("The merge was successful, closing the pull request.")
644 644 PullRequestModel().close_pull_request(
645 645 pull_request.pull_request_id, user)
646 646 Session().commit()
647 647 msg = _('Pull request was successfully merged and closed.')
648 648 h.flash(msg, category='success')
649 649 else:
650 650 log.debug(
651 651 "The merge was not successful. Merge response: %s",
652 652 merge_resp)
653 653 msg = PullRequestModel().merge_status_message(
654 654 merge_resp.failure_reason)
655 655 h.flash(msg, category='error')
656 656
657 657 def _update_reviewers(self, pull_request_id, review_members):
658 658 reviewers = [
659 659 (int(r['user_id']), r['reasons']) for r in review_members]
660 660 PullRequestModel().update_reviewers(pull_request_id, reviewers)
661 661 Session().commit()
662 662
663 663 def _reject_close(self, pull_request):
664 664 if pull_request.is_closed():
665 665 raise HTTPForbidden()
666 666
667 667 PullRequestModel().close_pull_request_with_comment(
668 668 pull_request, c.rhodecode_user, c.rhodecode_db_repo)
669 669 Session().commit()
670 670
671 671 @LoginRequired()
672 672 @NotAnonymous()
673 673 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
674 674 'repository.admin')
675 675 @auth.CSRFRequired()
676 676 @jsonify
677 677 def delete(self, repo_name, pull_request_id):
678 678 pull_request_id = safe_int(pull_request_id)
679 679 pull_request = PullRequest.get_or_404(pull_request_id)
680 680 # only owner can delete it !
681 681 if pull_request.author.user_id == c.rhodecode_user.user_id:
682 682 PullRequestModel().delete(pull_request)
683 683 Session().commit()
684 684 h.flash(_('Successfully deleted pull request'),
685 685 category='success')
686 686 return redirect(url('my_account_pullrequests'))
687 687 raise HTTPForbidden()
688 688
689 689 def _get_pr_version(self, pull_request_id, version=None):
690 690 pull_request_id = safe_int(pull_request_id)
691 691 at_version = None
692 692
693 693 if version and version == 'latest':
694 694 pull_request_ver = PullRequest.get(pull_request_id)
695 695 pull_request_obj = pull_request_ver
696 696 _org_pull_request_obj = pull_request_obj
697 697 at_version = 'latest'
698 698 elif version:
699 699 pull_request_ver = PullRequestVersion.get_or_404(version)
700 700 pull_request_obj = pull_request_ver
701 701 _org_pull_request_obj = pull_request_ver.pull_request
702 702 at_version = pull_request_ver.pull_request_version_id
703 703 else:
704 704 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(pull_request_id)
705 705
706 706 pull_request_display_obj = PullRequest.get_pr_display_object(
707 707 pull_request_obj, _org_pull_request_obj)
708 708 return _org_pull_request_obj, pull_request_obj, \
709 709 pull_request_display_obj, at_version
710 710
711 711 def _get_pr_version_changes(self, version, pull_request_latest):
712 712 """
713 713 Generate changes commits, and diff data based on the current pr version
714 714 """
715 715
716 716 #TODO(marcink): save those changes as JSON metadata for chaching later.
717 717
718 718 # fake the version to add the "initial" state object
719 719 pull_request_initial = PullRequest.get_pr_display_object(
720 720 pull_request_latest, pull_request_latest,
721 721 internal_methods=['get_commit', 'versions'])
722 722 pull_request_initial.revisions = []
723 723 pull_request_initial.source_repo.get_commit = types.MethodType(
724 724 lambda *a, **k: EmptyCommit(), pull_request_initial)
725 725 pull_request_initial.source_repo.scm_instance = types.MethodType(
726 726 lambda *a, **k: EmptyRepository(), pull_request_initial)
727 727
728 728 _changes_versions = [pull_request_latest] + \
729 729 list(reversed(c.versions)) + \
730 730 [pull_request_initial]
731 731
732 732 if version == 'latest':
733 733 index = 0
734 734 else:
735 735 for pos, prver in enumerate(_changes_versions):
736 736 ver = getattr(prver, 'pull_request_version_id', -1)
737 737 if ver == safe_int(version):
738 738 index = pos
739 739 break
740 740 else:
741 741 index = 0
742 742
743 743 cur_obj = _changes_versions[index]
744 744 prev_obj = _changes_versions[index + 1]
745 745
746 746 old_commit_ids = set(prev_obj.revisions)
747 747 new_commit_ids = set(cur_obj.revisions)
748 748
749 749 changes = PullRequestModel()._calculate_commit_id_changes(
750 750 old_commit_ids, new_commit_ids)
751 751
752 752 old_diff_data, new_diff_data = PullRequestModel()._generate_update_diffs(
753 753 cur_obj, prev_obj)
754 754 file_changes = PullRequestModel()._calculate_file_changes(
755 755 old_diff_data, new_diff_data)
756 756 return changes, file_changes
757 757
758 758 @LoginRequired()
759 759 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
760 760 'repository.admin')
761 761 def show(self, repo_name, pull_request_id):
762 762 pull_request_id = safe_int(pull_request_id)
763 763 version = request.GET.get('version')
764 764
765 765 (pull_request_latest,
766 766 pull_request_at_ver,
767 767 pull_request_display_obj,
768 768 at_version) = self._get_pr_version(pull_request_id, version=version)
769 769
770 770 c.template_context['pull_request_data']['pull_request_id'] = \
771 771 pull_request_id
772 772
773 773 # pull_requests repo_name we opened it against
774 774 # ie. target_repo must match
775 775 if repo_name != pull_request_at_ver.target_repo.repo_name:
776 776 raise HTTPNotFound
777 777
778 778 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
779 779 pull_request_at_ver)
780 780
781 781 pr_closed = pull_request_latest.is_closed()
782 782 if at_version and not at_version == 'latest':
783 783 c.allowed_to_change_status = False
784 784 c.allowed_to_update = False
785 785 c.allowed_to_merge = False
786 786 c.allowed_to_delete = False
787 787 c.allowed_to_comment = False
788 788 else:
789 789 c.allowed_to_change_status = PullRequestModel(). \
790 790 check_user_change_status(pull_request_at_ver, c.rhodecode_user)
791 791 c.allowed_to_update = PullRequestModel().check_user_update(
792 792 pull_request_latest, c.rhodecode_user) and not pr_closed
793 793 c.allowed_to_merge = PullRequestModel().check_user_merge(
794 794 pull_request_latest, c.rhodecode_user) and not pr_closed
795 795 c.allowed_to_delete = PullRequestModel().check_user_delete(
796 796 pull_request_latest, c.rhodecode_user) and not pr_closed
797 797 c.allowed_to_comment = not pr_closed
798 798
799 cc_model = ChangesetCommentsModel()
799 cc_model = CommentsModel()
800 800
801 801 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
802 802 c.pull_request_review_status = pull_request_at_ver.calculated_review_status()
803 803 c.pr_merge_status, c.pr_merge_msg = PullRequestModel().merge_status(
804 804 pull_request_at_ver)
805 805 c.approval_msg = None
806 806 if c.pull_request_review_status != ChangesetStatus.STATUS_APPROVED:
807 807 c.approval_msg = _('Reviewer approval is pending.')
808 808 c.pr_merge_status = False
809 809
810 810 # inline comments
811 811 inline_comments = cc_model.get_inline_comments(
812 812 c.rhodecode_db_repo.repo_id, pull_request=pull_request_id)
813 813
814 814 _inline_cnt, c.inline_versions = cc_model.get_inline_comments_count(
815 815 inline_comments, version=at_version, include_aggregates=True)
816 816
817 817 c.versions = pull_request_display_obj.versions()
818 818 c.at_version_num = at_version if at_version and at_version != 'latest' else None
819 819 c.at_version_pos = ChangesetComment.get_index_from_version(
820 820 c.at_version_num, c.versions)
821 821
822 822 is_outdated = lambda co: \
823 823 not c.at_version_num \
824 824 or co.pull_request_version_id <= c.at_version_num
825 825
826 826 # inline_comments_until_version
827 827 if c.at_version_num:
828 828 # if we use version, then do not show later comments
829 829 # than current version
830 830 paths = collections.defaultdict(lambda: collections.defaultdict(list))
831 831 for fname, per_line_comments in inline_comments.iteritems():
832 832 for lno, comments in per_line_comments.iteritems():
833 833 for co in comments:
834 834 if co.pull_request_version_id and is_outdated(co):
835 835 paths[co.f_path][co.line_no].append(co)
836 836 inline_comments = paths
837 837
838 838 # outdated comments
839 839 c.outdated_cnt = 0
840 if ChangesetCommentsModel.use_outdated_comments(pull_request_latest):
840 if CommentsModel.use_outdated_comments(pull_request_latest):
841 841 outdated_comments = cc_model.get_outdated_comments(
842 842 c.rhodecode_db_repo.repo_id,
843 843 pull_request=pull_request_at_ver)
844 844
845 845 # Count outdated comments and check for deleted files
846 846 is_outdated = lambda co: \
847 847 not c.at_version_num \
848 848 or co.pull_request_version_id < c.at_version_num
849 849 for file_name, lines in outdated_comments.iteritems():
850 850 for comments in lines.values():
851 851 comments = [comm for comm in comments if is_outdated(comm)]
852 852 c.outdated_cnt += len(comments)
853 853
854 854 # load compare data into template context
855 855 self._load_compare_data(pull_request_at_ver, inline_comments)
856 856
857 857 # this is a hack to properly display links, when creating PR, the
858 858 # compare view and others uses different notation, and
859 859 # compare_commits.mako renders links based on the target_repo.
860 860 # We need to swap that here to generate it properly on the html side
861 861 c.target_repo = c.source_repo
862 862
863 863 # general comments
864 864 c.comments = cc_model.get_comments(
865 865 c.rhodecode_db_repo.repo_id, pull_request=pull_request_id)
866 866
867 867 if c.allowed_to_update:
868 868 force_close = ('forced_closed', _('Close Pull Request'))
869 869 statuses = ChangesetStatus.STATUSES + [force_close]
870 870 else:
871 871 statuses = ChangesetStatus.STATUSES
872 872 c.commit_statuses = statuses
873 873
874 874 c.ancestor = None # TODO: add ancestor here
875 875 c.pull_request = pull_request_display_obj
876 876 c.pull_request_latest = pull_request_latest
877 877 c.at_version = at_version
878 878
879 879 c.changes = None
880 880 c.file_changes = None
881 881
882 882 c.show_version_changes = 1 # control flag, not used yet
883 883
884 884 if at_version and c.show_version_changes:
885 885 c.changes, c.file_changes = self._get_pr_version_changes(
886 886 version, pull_request_latest)
887 887
888 888 return render('/pullrequests/pullrequest_show.mako')
889 889
890 890 @LoginRequired()
891 891 @NotAnonymous()
892 892 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
893 893 'repository.admin')
894 894 @auth.CSRFRequired()
895 895 @jsonify
896 896 def comment(self, repo_name, pull_request_id):
897 897 pull_request_id = safe_int(pull_request_id)
898 898 pull_request = PullRequest.get_or_404(pull_request_id)
899 899 if pull_request.is_closed():
900 900 raise HTTPForbidden()
901 901
902 902 # TODO: johbo: Re-think this bit, "approved_closed" does not exist
903 903 # as a changeset status, still we want to send it in one value.
904 904 status = request.POST.get('changeset_status', None)
905 905 text = request.POST.get('text')
906 906 if status and '_closed' in status:
907 907 close_pr = True
908 908 status = status.replace('_closed', '')
909 909 else:
910 910 close_pr = False
911 911
912 912 forced = (status == 'forced')
913 913 if forced:
914 914 status = 'rejected'
915 915
916 916 allowed_to_change_status = PullRequestModel().check_user_change_status(
917 917 pull_request, c.rhodecode_user)
918 918
919 919 if status and allowed_to_change_status:
920 920 message = (_('Status change %(transition_icon)s %(status)s')
921 921 % {'transition_icon': '>',
922 922 'status': ChangesetStatus.get_status_lbl(status)})
923 923 if close_pr:
924 924 message = _('Closing with') + ' ' + message
925 925 text = text or message
926 comm = ChangesetCommentsModel().create(
926 comm = CommentsModel().create(
927 927 text=text,
928 928 repo=c.rhodecode_db_repo.repo_id,
929 929 user=c.rhodecode_user.user_id,
930 930 pull_request=pull_request_id,
931 931 f_path=request.POST.get('f_path'),
932 932 line_no=request.POST.get('line'),
933 933 status_change=(ChangesetStatus.get_status_lbl(status)
934 934 if status and allowed_to_change_status else None),
935 935 status_change_type=(status
936 936 if status and allowed_to_change_status else None),
937 937 closing_pr=close_pr
938 938 )
939 939
940 940 if allowed_to_change_status:
941 941 old_calculated_status = pull_request.calculated_review_status()
942 942 # get status if set !
943 943 if status:
944 944 ChangesetStatusModel().set_status(
945 945 c.rhodecode_db_repo.repo_id,
946 946 status,
947 947 c.rhodecode_user.user_id,
948 948 comm,
949 949 pull_request=pull_request_id
950 950 )
951 951
952 952 Session().flush()
953 953 events.trigger(events.PullRequestCommentEvent(pull_request, comm))
954 954 # we now calculate the status of pull request, and based on that
955 955 # calculation we set the commits status
956 956 calculated_status = pull_request.calculated_review_status()
957 957 if old_calculated_status != calculated_status:
958 958 PullRequestModel()._trigger_pull_request_hook(
959 959 pull_request, c.rhodecode_user, 'review_status_change')
960 960
961 961 calculated_status_lbl = ChangesetStatus.get_status_lbl(
962 962 calculated_status)
963 963
964 964 if close_pr:
965 965 status_completed = (
966 966 calculated_status in [ChangesetStatus.STATUS_APPROVED,
967 967 ChangesetStatus.STATUS_REJECTED])
968 968 if forced or status_completed:
969 969 PullRequestModel().close_pull_request(
970 970 pull_request_id, c.rhodecode_user)
971 971 else:
972 972 h.flash(_('Closing pull request on other statuses than '
973 973 'rejected or approved is forbidden. '
974 974 'Calculated status from all reviewers '
975 975 'is currently: %s') % calculated_status_lbl,
976 976 category='warning')
977 977
978 978 Session().commit()
979 979
980 980 if not request.is_xhr:
981 981 return redirect(h.url('pullrequest_show', repo_name=repo_name,
982 982 pull_request_id=pull_request_id))
983 983
984 984 data = {
985 985 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
986 986 }
987 987 if comm:
988 988 c.co = comm
989 989 c.inline_comment = True if comm.line_no else False
990 990 data.update(comm.get_dict())
991 991 data.update({'rendered_text':
992 992 render('changeset/changeset_comment_block.mako')})
993 993
994 994 return data
995 995
996 996 @LoginRequired()
997 997 @NotAnonymous()
998 998 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
999 999 'repository.admin')
1000 1000 @auth.CSRFRequired()
1001 1001 @jsonify
1002 1002 def delete_comment(self, repo_name, comment_id):
1003 1003 return self._delete_comment(comment_id)
1004 1004
1005 1005 def _delete_comment(self, comment_id):
1006 1006 comment_id = safe_int(comment_id)
1007 1007 co = ChangesetComment.get_or_404(comment_id)
1008 1008 if co.pull_request.is_closed():
1009 1009 # don't allow deleting comments on closed pull request
1010 1010 raise HTTPForbidden()
1011 1011
1012 1012 is_owner = co.author.user_id == c.rhodecode_user.user_id
1013 1013 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
1014 1014 if h.HasPermissionAny('hg.admin')() or is_repo_admin or is_owner:
1015 1015 old_calculated_status = co.pull_request.calculated_review_status()
1016 ChangesetCommentsModel().delete(comment=co)
1016 CommentsModel().delete(comment=co)
1017 1017 Session().commit()
1018 1018 calculated_status = co.pull_request.calculated_review_status()
1019 1019 if old_calculated_status != calculated_status:
1020 1020 PullRequestModel()._trigger_pull_request_hook(
1021 1021 co.pull_request, c.rhodecode_user, 'review_status_change')
1022 1022 return True
1023 1023 else:
1024 1024 raise HTTPForbidden()
@@ -1,131 +1,131 b''
1 1 # Copyright (C) 2016-2017 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19
20 20 from rhodecode.translation import lazy_ugettext
21 21 from rhodecode.events.repo import (
22 22 RepoEvent, _commits_as_dict, _issues_as_dict)
23 23
24 24
25 25 class PullRequestEvent(RepoEvent):
26 26 """
27 27 Base class for pull request events.
28 28
29 29 :param pullrequest: a :class:`PullRequest` instance
30 30 """
31 31
32 32 def __init__(self, pullrequest):
33 33 super(PullRequestEvent, self).__init__(pullrequest.target_repo)
34 34 self.pullrequest = pullrequest
35 35
36 36 def as_dict(self):
37 37 from rhodecode.model.pull_request import PullRequestModel
38 38 data = super(PullRequestEvent, self).as_dict()
39 39
40 40 commits = _commits_as_dict(
41 41 commit_ids=self.pullrequest.revisions,
42 42 repos=[self.pullrequest.source_repo]
43 43 )
44 44 issues = _issues_as_dict(commits)
45 45
46 46 data.update({
47 47 'pullrequest': {
48 48 'title': self.pullrequest.title,
49 49 'issues': issues,
50 50 'pull_request_id': self.pullrequest.pull_request_id,
51 51 'url': PullRequestModel().get_url(self.pullrequest),
52 52 'status': self.pullrequest.calculated_review_status(),
53 53 'commits': commits,
54 54 }
55 55 })
56 56 return data
57 57
58 58
59 59 class PullRequestCreateEvent(PullRequestEvent):
60 60 """
61 61 An instance of this class is emitted as an :term:`event` after a pull
62 62 request is created.
63 63 """
64 64 name = 'pullrequest-create'
65 65 display_name = lazy_ugettext('pullrequest created')
66 66
67 67
68 68 class PullRequestCloseEvent(PullRequestEvent):
69 69 """
70 70 An instance of this class is emitted as an :term:`event` after a pull
71 71 request is closed.
72 72 """
73 73 name = 'pullrequest-close'
74 74 display_name = lazy_ugettext('pullrequest closed')
75 75
76 76
77 77 class PullRequestUpdateEvent(PullRequestEvent):
78 78 """
79 79 An instance of this class is emitted as an :term:`event` after a pull
80 80 request's commits have been updated.
81 81 """
82 82 name = 'pullrequest-update'
83 83 display_name = lazy_ugettext('pullrequest commits updated')
84 84
85 85
86 86 class PullRequestReviewEvent(PullRequestEvent):
87 87 """
88 88 An instance of this class is emitted as an :term:`event` after a pull
89 89 request review has changed.
90 90 """
91 91 name = 'pullrequest-review'
92 92 display_name = lazy_ugettext('pullrequest review changed')
93 93
94 94
95 95 class PullRequestMergeEvent(PullRequestEvent):
96 96 """
97 97 An instance of this class is emitted as an :term:`event` after a pull
98 98 request is merged.
99 99 """
100 100 name = 'pullrequest-merge'
101 101 display_name = lazy_ugettext('pullrequest merged')
102 102
103 103
104 104 class PullRequestCommentEvent(PullRequestEvent):
105 105 """
106 106 An instance of this class is emitted as an :term:`event` after a pull
107 107 request comment is created.
108 108 """
109 109 name = 'pullrequest-comment'
110 110 display_name = lazy_ugettext('pullrequest commented')
111 111
112 112 def __init__(self, pullrequest, comment):
113 113 super(PullRequestCommentEvent, self).__init__(pullrequest)
114 114 self.comment = comment
115 115
116 116 def as_dict(self):
117 from rhodecode.model.comment import ChangesetCommentsModel
117 from rhodecode.model.comment import CommentsModel
118 118 data = super(PullRequestCommentEvent, self).as_dict()
119 119
120 120 status = None
121 121 if self.comment.status_change:
122 122 status = self.comment.status_change[0].status
123 123
124 124 data.update({
125 125 'comment': {
126 126 'status': status,
127 127 'text': self.comment.text,
128 'url': ChangesetCommentsModel().get_url(self.comment)
128 'url': CommentsModel().get_url(self.comment)
129 129 }
130 130 })
131 131 return data
@@ -1,268 +1,268 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 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 Changeset status conttroller
23 23 """
24 24
25 25 import itertools
26 26 import logging
27 27 from collections import defaultdict
28 28
29 29 from rhodecode.model import BaseModel
30 30 from rhodecode.model.db import ChangesetStatus, ChangesetComment, PullRequest
31 31 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError
32 32 from rhodecode.lib.markup_renderer import (
33 33 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
34 34
35 35 log = logging.getLogger(__name__)
36 36
37 37
38 38 class ChangesetStatusModel(BaseModel):
39 39
40 40 cls = ChangesetStatus
41 41
42 42 def __get_changeset_status(self, changeset_status):
43 43 return self._get_instance(ChangesetStatus, changeset_status)
44 44
45 45 def __get_pull_request(self, pull_request):
46 46 return self._get_instance(PullRequest, pull_request)
47 47
48 48 def _get_status_query(self, repo, revision, pull_request,
49 49 with_revisions=False):
50 50 repo = self._get_repo(repo)
51 51
52 52 q = ChangesetStatus.query()\
53 53 .filter(ChangesetStatus.repo == repo)
54 54 if not with_revisions:
55 55 q = q.filter(ChangesetStatus.version == 0)
56 56
57 57 if revision:
58 58 q = q.filter(ChangesetStatus.revision == revision)
59 59 elif pull_request:
60 60 pull_request = self.__get_pull_request(pull_request)
61 61 # TODO: johbo: Think about the impact of this join, there must
62 62 # be a reason why ChangesetStatus and ChanagesetComment is linked
63 63 # to the pull request. Might be that we want to do the same for
64 64 # the pull_request_version_id.
65 65 q = q.join(ChangesetComment).filter(
66 66 ChangesetStatus.pull_request == pull_request,
67 67 ChangesetComment.pull_request_version_id == None)
68 68 else:
69 69 raise Exception('Please specify revision or pull_request')
70 70 q = q.order_by(ChangesetStatus.version.asc())
71 71 return q
72 72
73 73 def calculate_status(self, statuses_by_reviewers):
74 74 """
75 75 Given the approval statuses from reviewers, calculates final approval
76 76 status. There can only be 3 results, all approved, all rejected. If
77 77 there is no consensus the PR is under review.
78 78
79 79 :param statuses_by_reviewers:
80 80 """
81 81 votes = defaultdict(int)
82 82 reviewers_number = len(statuses_by_reviewers)
83 83 for user, reasons, statuses in statuses_by_reviewers:
84 84 if statuses:
85 85 ver, latest = statuses[0]
86 86 votes[latest.status] += 1
87 87 else:
88 88 votes[ChangesetStatus.DEFAULT] += 1
89 89
90 90 # all approved
91 91 if votes.get(ChangesetStatus.STATUS_APPROVED) == reviewers_number:
92 92 return ChangesetStatus.STATUS_APPROVED
93 93
94 94 # all rejected
95 95 if votes.get(ChangesetStatus.STATUS_REJECTED) == reviewers_number:
96 96 return ChangesetStatus.STATUS_REJECTED
97 97
98 98 return ChangesetStatus.STATUS_UNDER_REVIEW
99 99
100 100 def get_statuses(self, repo, revision=None, pull_request=None,
101 101 with_revisions=False):
102 102 q = self._get_status_query(repo, revision, pull_request,
103 103 with_revisions)
104 104 return q.all()
105 105
106 106 def get_status(self, repo, revision=None, pull_request=None, as_str=True):
107 107 """
108 108 Returns latest status of changeset for given revision or for given
109 109 pull request. Statuses are versioned inside a table itself and
110 110 version == 0 is always the current one
111 111
112 112 :param repo:
113 113 :param revision: 40char hash or None
114 114 :param pull_request: pull_request reference
115 115 :param as_str: return status as string not object
116 116 """
117 117 q = self._get_status_query(repo, revision, pull_request)
118 118
119 119 # need to use first here since there can be multiple statuses
120 120 # returned from pull_request
121 121 status = q.first()
122 122 if as_str:
123 123 status = status.status if status else status
124 124 st = status or ChangesetStatus.DEFAULT
125 125 return str(st)
126 126 return status
127 127
128 128 def _render_auto_status_message(
129 129 self, status, commit_id=None, pull_request=None):
130 130 """
131 131 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
132 132 so it's always looking the same disregarding on which default
133 133 renderer system is using.
134 134
135 135 :param status: status text to change into
136 136 :param commit_id: the commit_id we change the status for
137 137 :param pull_request: the pull request we change the status for
138 138 """
139 139
140 140 new_status = ChangesetStatus.get_status_lbl(status)
141 141
142 142 params = {
143 143 'new_status_label': new_status,
144 144 'pull_request': pull_request,
145 145 'commit_id': commit_id,
146 146 }
147 147 renderer = RstTemplateRenderer()
148 148 return renderer.render('auto_status_change.mako', **params)
149 149
150 150 def set_status(self, repo, status, user, comment=None, revision=None,
151 151 pull_request=None, dont_allow_on_closed_pull_request=False):
152 152 """
153 153 Creates new status for changeset or updates the old ones bumping their
154 154 version, leaving the current status at
155 155
156 156 :param repo:
157 157 :param revision:
158 158 :param status:
159 159 :param user:
160 160 :param comment:
161 161 :param dont_allow_on_closed_pull_request: don't allow a status change
162 162 if last status was for pull request and it's closed. We shouldn't
163 163 mess around this manually
164 164 """
165 165 repo = self._get_repo(repo)
166 166
167 167 q = ChangesetStatus.query()
168 168
169 169 if revision:
170 170 q = q.filter(ChangesetStatus.repo == repo)
171 171 q = q.filter(ChangesetStatus.revision == revision)
172 172 elif pull_request:
173 173 pull_request = self.__get_pull_request(pull_request)
174 174 q = q.filter(ChangesetStatus.repo == pull_request.source_repo)
175 175 q = q.filter(ChangesetStatus.revision.in_(pull_request.revisions))
176 176 cur_statuses = q.all()
177 177
178 178 # if statuses exists and last is associated with a closed pull request
179 179 # we need to check if we can allow this status change
180 180 if (dont_allow_on_closed_pull_request and cur_statuses
181 181 and getattr(cur_statuses[0].pull_request, 'status', '')
182 182 == PullRequest.STATUS_CLOSED):
183 183 raise StatusChangeOnClosedPullRequestError(
184 184 'Changing status on closed pull request is not allowed'
185 185 )
186 186
187 187 # update all current statuses with older version
188 188 if cur_statuses:
189 189 for st in cur_statuses:
190 190 st.version += 1
191 191 self.sa.add(st)
192 192
193 193 def _create_status(user, repo, status, comment, revision, pull_request):
194 194 new_status = ChangesetStatus()
195 195 new_status.author = self._get_user(user)
196 196 new_status.repo = self._get_repo(repo)
197 197 new_status.status = status
198 198 new_status.comment = comment
199 199 new_status.revision = revision
200 200 new_status.pull_request = pull_request
201 201 return new_status
202 202
203 203 if not comment:
204 from rhodecode.model.comment import ChangesetCommentsModel
205 comment = ChangesetCommentsModel().create(
204 from rhodecode.model.comment import CommentsModel
205 comment = CommentsModel().create(
206 206 text=self._render_auto_status_message(
207 207 status, commit_id=revision, pull_request=pull_request),
208 208 repo=repo,
209 209 user=user,
210 210 pull_request=pull_request,
211 211 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER
212 212 )
213 213
214 214 if revision:
215 215 new_status = _create_status(
216 216 user=user, repo=repo, status=status, comment=comment,
217 217 revision=revision, pull_request=pull_request)
218 218 self.sa.add(new_status)
219 219 return new_status
220 220 elif pull_request:
221 221 # pull request can have more than one revision associated to it
222 222 # we need to create new version for each one
223 223 new_statuses = []
224 224 repo = pull_request.source_repo
225 225 for rev in pull_request.revisions:
226 226 new_status = _create_status(
227 227 user=user, repo=repo, status=status, comment=comment,
228 228 revision=rev, pull_request=pull_request)
229 229 new_statuses.append(new_status)
230 230 self.sa.add(new_status)
231 231 return new_statuses
232 232
233 233 def reviewers_statuses(self, pull_request):
234 234 _commit_statuses = self.get_statuses(
235 235 pull_request.source_repo,
236 236 pull_request=pull_request,
237 237 with_revisions=True)
238 238
239 239 commit_statuses = defaultdict(list)
240 240 for st in _commit_statuses:
241 241 commit_statuses[st.author.username] += [st]
242 242
243 243 pull_request_reviewers = []
244 244
245 245 def version(commit_status):
246 246 return commit_status.version
247 247
248 248 for o in pull_request.reviewers:
249 249 if not o.user:
250 250 continue
251 251 st = commit_statuses.get(o.user.username, None)
252 252 if st:
253 253 st = [(x, list(y)[0])
254 254 for x, y in (itertools.groupby(sorted(st, key=version),
255 255 version))]
256 256
257 257 pull_request_reviewers.append((o.user, o.reasons, st))
258 258 return pull_request_reviewers
259 259
260 260 def calculated_review_status(self, pull_request, reviewers_statuses=None):
261 261 """
262 262 calculate pull request status based on reviewers, it should be a list
263 263 of two element lists.
264 264
265 265 :param reviewers_statuses:
266 266 """
267 267 reviewers = reviewers_statuses or self.reviewers_statuses(pull_request)
268 268 return self.calculate_status(reviewers)
@@ -1,530 +1,530 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2017 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 comments model for RhodeCode
23 23 """
24 24
25 25 import logging
26 26 import traceback
27 27 import collections
28 28
29 29 from datetime import datetime
30 30
31 31 from pylons.i18n.translation import _
32 32 from pyramid.threadlocal import get_current_registry
33 33 from sqlalchemy.sql.expression import null
34 34 from sqlalchemy.sql.functions import coalesce
35 35
36 36 from rhodecode.lib import helpers as h, diffs
37 37 from rhodecode.lib.channelstream import channelstream_request
38 38 from rhodecode.lib.utils import action_logger
39 39 from rhodecode.lib.utils2 import extract_mentioned_users
40 40 from rhodecode.model import BaseModel
41 41 from rhodecode.model.db import (
42 42 ChangesetComment, User, Notification, PullRequest)
43 43 from rhodecode.model.notification import NotificationModel
44 44 from rhodecode.model.meta import Session
45 45 from rhodecode.model.settings import VcsSettingsModel
46 46 from rhodecode.model.notification import EmailNotificationModel
47 47
48 48 log = logging.getLogger(__name__)
49 49
50 50
51 class ChangesetCommentsModel(BaseModel):
51 class CommentsModel(BaseModel):
52 52
53 53 cls = ChangesetComment
54 54
55 55 DIFF_CONTEXT_BEFORE = 3
56 56 DIFF_CONTEXT_AFTER = 3
57 57
58 58 def __get_commit_comment(self, changeset_comment):
59 59 return self._get_instance(ChangesetComment, changeset_comment)
60 60
61 61 def __get_pull_request(self, pull_request):
62 62 return self._get_instance(PullRequest, pull_request)
63 63
64 64 def _extract_mentions(self, s):
65 65 user_objects = []
66 66 for username in extract_mentioned_users(s):
67 67 user_obj = User.get_by_username(username, case_insensitive=True)
68 68 if user_obj:
69 69 user_objects.append(user_obj)
70 70 return user_objects
71 71
72 72 def _get_renderer(self, global_renderer='rst'):
73 73 try:
74 74 # try reading from visual context
75 75 from pylons import tmpl_context
76 76 global_renderer = tmpl_context.visual.default_renderer
77 77 except AttributeError:
78 78 log.debug("Renderer not set, falling back "
79 79 "to default renderer '%s'", global_renderer)
80 80 except Exception:
81 81 log.error(traceback.format_exc())
82 82 return global_renderer
83 83
84 84 def create(self, text, repo, user, commit_id=None, pull_request=None,
85 85 f_path=None, line_no=None, status_change=None, comment_type=None,
86 86 status_change_type=None, closing_pr=False,
87 87 send_email=True, renderer=None):
88 88 """
89 89 Creates new comment for commit or pull request.
90 90 IF status_change is not none this comment is associated with a
91 91 status change of commit or commit associated with pull request
92 92
93 93 :param text:
94 94 :param repo:
95 95 :param user:
96 96 :param commit_id:
97 97 :param pull_request:
98 98 :param f_path:
99 99 :param line_no:
100 100 :param status_change: Label for status change
101 101 :param comment_type: Type of comment
102 102 :param status_change_type: type of status change
103 103 :param closing_pr:
104 104 :param send_email:
105 105 :param renderer: pick renderer for this comment
106 106 """
107 107 if not text:
108 108 log.warning('Missing text for comment, skipping...')
109 109 return
110 110
111 111 if not renderer:
112 112 renderer = self._get_renderer()
113 113
114 114 repo = self._get_repo(repo)
115 115 user = self._get_user(user)
116 116 comment = ChangesetComment()
117 117 comment.renderer = renderer
118 118 comment.repo = repo
119 119 comment.author = user
120 120 comment.text = text
121 121 comment.f_path = f_path
122 122 comment.line_no = line_no
123 123
124 124 pull_request_id = pull_request
125 125
126 126 commit_obj = None
127 127 pull_request_obj = None
128 128
129 129 if commit_id:
130 130 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
131 131 # do a lookup, so we don't pass something bad here
132 132 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
133 133 comment.revision = commit_obj.raw_id
134 134
135 135 elif pull_request_id:
136 136 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
137 137 pull_request_obj = self.__get_pull_request(pull_request_id)
138 138 comment.pull_request = pull_request_obj
139 139 else:
140 140 raise Exception('Please specify commit or pull_request_id')
141 141
142 142 Session().add(comment)
143 143 Session().flush()
144 144 kwargs = {
145 145 'user': user,
146 146 'renderer_type': renderer,
147 147 'repo_name': repo.repo_name,
148 148 'status_change': status_change,
149 149 'status_change_type': status_change_type,
150 150 'comment_body': text,
151 151 'comment_file': f_path,
152 152 'comment_line': line_no,
153 153 }
154 154
155 155 if commit_obj:
156 156 recipients = ChangesetComment.get_users(
157 157 revision=commit_obj.raw_id)
158 158 # add commit author if it's in RhodeCode system
159 159 cs_author = User.get_from_cs_author(commit_obj.author)
160 160 if not cs_author:
161 161 # use repo owner if we cannot extract the author correctly
162 162 cs_author = repo.user
163 163 recipients += [cs_author]
164 164
165 165 commit_comment_url = self.get_url(comment)
166 166
167 167 target_repo_url = h.link_to(
168 168 repo.repo_name,
169 169 h.url('summary_home',
170 170 repo_name=repo.repo_name, qualified=True))
171 171
172 172 # commit specifics
173 173 kwargs.update({
174 174 'commit': commit_obj,
175 175 'commit_message': commit_obj.message,
176 176 'commit_target_repo': target_repo_url,
177 177 'commit_comment_url': commit_comment_url,
178 178 })
179 179
180 180 elif pull_request_obj:
181 181 # get the current participants of this pull request
182 182 recipients = ChangesetComment.get_users(
183 183 pull_request_id=pull_request_obj.pull_request_id)
184 184 # add pull request author
185 185 recipients += [pull_request_obj.author]
186 186
187 187 # add the reviewers to notification
188 188 recipients += [x.user for x in pull_request_obj.reviewers]
189 189
190 190 pr_target_repo = pull_request_obj.target_repo
191 191 pr_source_repo = pull_request_obj.source_repo
192 192
193 193 pr_comment_url = h.url(
194 194 'pullrequest_show',
195 195 repo_name=pr_target_repo.repo_name,
196 196 pull_request_id=pull_request_obj.pull_request_id,
197 197 anchor='comment-%s' % comment.comment_id,
198 198 qualified=True,)
199 199
200 200 # set some variables for email notification
201 201 pr_target_repo_url = h.url(
202 202 'summary_home', repo_name=pr_target_repo.repo_name,
203 203 qualified=True)
204 204
205 205 pr_source_repo_url = h.url(
206 206 'summary_home', repo_name=pr_source_repo.repo_name,
207 207 qualified=True)
208 208
209 209 # pull request specifics
210 210 kwargs.update({
211 211 'pull_request': pull_request_obj,
212 212 'pr_id': pull_request_obj.pull_request_id,
213 213 'pr_target_repo': pr_target_repo,
214 214 'pr_target_repo_url': pr_target_repo_url,
215 215 'pr_source_repo': pr_source_repo,
216 216 'pr_source_repo_url': pr_source_repo_url,
217 217 'pr_comment_url': pr_comment_url,
218 218 'pr_closing': closing_pr,
219 219 })
220 220 if send_email:
221 221 # pre-generate the subject for notification itself
222 222 (subject,
223 223 _h, _e, # we don't care about those
224 224 body_plaintext) = EmailNotificationModel().render_email(
225 225 notification_type, **kwargs)
226 226
227 227 mention_recipients = set(
228 228 self._extract_mentions(text)).difference(recipients)
229 229
230 230 # create notification objects, and emails
231 231 NotificationModel().create(
232 232 created_by=user,
233 233 notification_subject=subject,
234 234 notification_body=body_plaintext,
235 235 notification_type=notification_type,
236 236 recipients=recipients,
237 237 mention_recipients=mention_recipients,
238 238 email_kwargs=kwargs,
239 239 )
240 240
241 241 action = (
242 242 'user_commented_pull_request:{}'.format(
243 243 comment.pull_request.pull_request_id)
244 244 if comment.pull_request
245 245 else 'user_commented_revision:{}'.format(comment.revision)
246 246 )
247 247 action_logger(user, action, comment.repo)
248 248
249 249 registry = get_current_registry()
250 250 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
251 251 channelstream_config = rhodecode_plugins.get('channelstream', {})
252 252 msg_url = ''
253 253 if commit_obj:
254 254 msg_url = commit_comment_url
255 255 repo_name = repo.repo_name
256 256 elif pull_request_obj:
257 257 msg_url = pr_comment_url
258 258 repo_name = pr_target_repo.repo_name
259 259
260 260 if channelstream_config.get('enabled'):
261 261 message = '<strong>{}</strong> {} - ' \
262 262 '<a onclick="window.location=\'{}\';' \
263 263 'window.location.reload()">' \
264 264 '<strong>{}</strong></a>'
265 265 message = message.format(
266 266 user.username, _('made a comment'), msg_url,
267 267 _('Show it now'))
268 268 channel = '/repo${}$/pr/{}'.format(
269 269 repo_name,
270 270 pull_request_id
271 271 )
272 272 payload = {
273 273 'type': 'message',
274 274 'timestamp': datetime.utcnow(),
275 275 'user': 'system',
276 276 'exclude_users': [user.username],
277 277 'channel': channel,
278 278 'message': {
279 279 'message': message,
280 280 'level': 'info',
281 281 'topic': '/notifications'
282 282 }
283 283 }
284 284 channelstream_request(channelstream_config, [payload],
285 285 '/message', raise_exc=False)
286 286
287 287 return comment
288 288
289 289 def delete(self, comment):
290 290 """
291 291 Deletes given comment
292 292
293 293 :param comment_id:
294 294 """
295 295 comment = self.__get_commit_comment(comment)
296 296 Session().delete(comment)
297 297
298 298 return comment
299 299
300 300 def get_all_comments(self, repo_id, revision=None, pull_request=None):
301 301 q = ChangesetComment.query()\
302 302 .filter(ChangesetComment.repo_id == repo_id)
303 303 if revision:
304 304 q = q.filter(ChangesetComment.revision == revision)
305 305 elif pull_request:
306 306 pull_request = self.__get_pull_request(pull_request)
307 307 q = q.filter(ChangesetComment.pull_request == pull_request)
308 308 else:
309 309 raise Exception('Please specify commit or pull_request')
310 310 q = q.order_by(ChangesetComment.created_on)
311 311 return q.all()
312 312
313 313 def get_url(self, comment):
314 314 comment = self.__get_commit_comment(comment)
315 315 if comment.pull_request:
316 316 return h.url(
317 317 'pullrequest_show',
318 318 repo_name=comment.pull_request.target_repo.repo_name,
319 319 pull_request_id=comment.pull_request.pull_request_id,
320 320 anchor='comment-%s' % comment.comment_id,
321 321 qualified=True,)
322 322 else:
323 323 return h.url(
324 324 'changeset_home',
325 325 repo_name=comment.repo.repo_name,
326 326 revision=comment.revision,
327 327 anchor='comment-%s' % comment.comment_id,
328 328 qualified=True,)
329 329
330 330 def get_comments(self, repo_id, revision=None, pull_request=None):
331 331 """
332 332 Gets main comments based on revision or pull_request_id
333 333
334 334 :param repo_id:
335 335 :param revision:
336 336 :param pull_request:
337 337 """
338 338
339 339 q = ChangesetComment.query()\
340 340 .filter(ChangesetComment.repo_id == repo_id)\
341 341 .filter(ChangesetComment.line_no == None)\
342 342 .filter(ChangesetComment.f_path == None)
343 343 if revision:
344 344 q = q.filter(ChangesetComment.revision == revision)
345 345 elif pull_request:
346 346 pull_request = self.__get_pull_request(pull_request)
347 347 q = q.filter(ChangesetComment.pull_request == pull_request)
348 348 else:
349 349 raise Exception('Please specify commit or pull_request')
350 350 q = q.order_by(ChangesetComment.created_on)
351 351 return q.all()
352 352
353 353 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
354 354 q = self._get_inline_comments_query(repo_id, revision, pull_request)
355 355 return self._group_comments_by_path_and_line_number(q)
356 356
357 357 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
358 358 version=None, include_aggregates=False):
359 359 version_aggregates = collections.defaultdict(list)
360 360 inline_cnt = 0
361 361 for fname, per_line_comments in inline_comments.iteritems():
362 362 for lno, comments in per_line_comments.iteritems():
363 363 for comm in comments:
364 364 version_aggregates[comm.pull_request_version_id].append(comm)
365 365 if not comm.outdated_at_version(version) and skip_outdated:
366 366 inline_cnt += 1
367 367
368 368 if include_aggregates:
369 369 return inline_cnt, version_aggregates
370 370 return inline_cnt
371 371
372 372 def get_outdated_comments(self, repo_id, pull_request):
373 373 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
374 374 # of a pull request.
375 375 q = self._all_inline_comments_of_pull_request(pull_request)
376 376 q = q.filter(
377 377 ChangesetComment.display_state ==
378 378 ChangesetComment.COMMENT_OUTDATED
379 379 ).order_by(ChangesetComment.comment_id.asc())
380 380
381 381 return self._group_comments_by_path_and_line_number(q)
382 382
383 383 def _get_inline_comments_query(self, repo_id, revision, pull_request):
384 384 # TODO: johbo: Split this into two methods: One for PR and one for
385 385 # commit.
386 386 if revision:
387 387 q = Session().query(ChangesetComment).filter(
388 388 ChangesetComment.repo_id == repo_id,
389 389 ChangesetComment.line_no != null(),
390 390 ChangesetComment.f_path != null(),
391 391 ChangesetComment.revision == revision)
392 392
393 393 elif pull_request:
394 394 pull_request = self.__get_pull_request(pull_request)
395 if not ChangesetCommentsModel.use_outdated_comments(pull_request):
395 if not CommentsModel.use_outdated_comments(pull_request):
396 396 q = self._visible_inline_comments_of_pull_request(pull_request)
397 397 else:
398 398 q = self._all_inline_comments_of_pull_request(pull_request)
399 399
400 400 else:
401 401 raise Exception('Please specify commit or pull_request_id')
402 402 q = q.order_by(ChangesetComment.comment_id.asc())
403 403 return q
404 404
405 405 def _group_comments_by_path_and_line_number(self, q):
406 406 comments = q.all()
407 407 paths = collections.defaultdict(lambda: collections.defaultdict(list))
408 408 for co in comments:
409 409 paths[co.f_path][co.line_no].append(co)
410 410 return paths
411 411
412 412 @classmethod
413 413 def needed_extra_diff_context(cls):
414 414 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
415 415
416 416 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
417 if not ChangesetCommentsModel.use_outdated_comments(pull_request):
417 if not CommentsModel.use_outdated_comments(pull_request):
418 418 return
419 419
420 420 comments = self._visible_inline_comments_of_pull_request(pull_request)
421 421 comments_to_outdate = comments.all()
422 422
423 423 for comment in comments_to_outdate:
424 424 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
425 425
426 426 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
427 427 diff_line = _parse_comment_line_number(comment.line_no)
428 428
429 429 try:
430 430 old_context = old_diff_proc.get_context_of_line(
431 431 path=comment.f_path, diff_line=diff_line)
432 432 new_context = new_diff_proc.get_context_of_line(
433 433 path=comment.f_path, diff_line=diff_line)
434 434 except (diffs.LineNotInDiffException,
435 435 diffs.FileNotInDiffException):
436 436 comment.display_state = ChangesetComment.COMMENT_OUTDATED
437 437 return
438 438
439 439 if old_context == new_context:
440 440 return
441 441
442 442 if self._should_relocate_diff_line(diff_line):
443 443 new_diff_lines = new_diff_proc.find_context(
444 444 path=comment.f_path, context=old_context,
445 445 offset=self.DIFF_CONTEXT_BEFORE)
446 446 if not new_diff_lines:
447 447 comment.display_state = ChangesetComment.COMMENT_OUTDATED
448 448 else:
449 449 new_diff_line = self._choose_closest_diff_line(
450 450 diff_line, new_diff_lines)
451 451 comment.line_no = _diff_to_comment_line_number(new_diff_line)
452 452 else:
453 453 comment.display_state = ChangesetComment.COMMENT_OUTDATED
454 454
455 455 def _should_relocate_diff_line(self, diff_line):
456 456 """
457 457 Checks if relocation shall be tried for the given `diff_line`.
458 458
459 459 If a comment points into the first lines, then we can have a situation
460 460 that after an update another line has been added on top. In this case
461 461 we would find the context still and move the comment around. This
462 462 would be wrong.
463 463 """
464 464 should_relocate = (
465 465 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
466 466 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
467 467 return should_relocate
468 468
469 469 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
470 470 candidate = new_diff_lines[0]
471 471 best_delta = _diff_line_delta(diff_line, candidate)
472 472 for new_diff_line in new_diff_lines[1:]:
473 473 delta = _diff_line_delta(diff_line, new_diff_line)
474 474 if delta < best_delta:
475 475 candidate = new_diff_line
476 476 best_delta = delta
477 477 return candidate
478 478
479 479 def _visible_inline_comments_of_pull_request(self, pull_request):
480 480 comments = self._all_inline_comments_of_pull_request(pull_request)
481 481 comments = comments.filter(
482 482 coalesce(ChangesetComment.display_state, '') !=
483 483 ChangesetComment.COMMENT_OUTDATED)
484 484 return comments
485 485
486 486 def _all_inline_comments_of_pull_request(self, pull_request):
487 487 comments = Session().query(ChangesetComment)\
488 488 .filter(ChangesetComment.line_no != None)\
489 489 .filter(ChangesetComment.f_path != None)\
490 490 .filter(ChangesetComment.pull_request == pull_request)
491 491 return comments
492 492
493 493 @staticmethod
494 494 def use_outdated_comments(pull_request):
495 495 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
496 496 settings = settings_model.get_general_settings()
497 497 return settings.get('rhodecode_use_outdated_comments', False)
498 498
499 499
500 500 def _parse_comment_line_number(line_no):
501 501 """
502 502 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
503 503 """
504 504 old_line = None
505 505 new_line = None
506 506 if line_no.startswith('o'):
507 507 old_line = int(line_no[1:])
508 508 elif line_no.startswith('n'):
509 509 new_line = int(line_no[1:])
510 510 else:
511 511 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
512 512 return diffs.DiffLineNumber(old_line, new_line)
513 513
514 514
515 515 def _diff_to_comment_line_number(diff_line):
516 516 if diff_line.new is not None:
517 517 return u'n{}'.format(diff_line.new)
518 518 elif diff_line.old is not None:
519 519 return u'o{}'.format(diff_line.old)
520 520 return u''
521 521
522 522
523 523 def _diff_line_delta(a, b):
524 524 if None not in (a.new, b.new):
525 525 return abs(a.new - b.new)
526 526 elif None not in (a.old, b.old):
527 527 return abs(a.old - b.old)
528 528 else:
529 529 raise ValueError(
530 530 "Cannot compute delta between {} and {}".format(a, b))
@@ -1,1318 +1,1318 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2017 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 from collections import namedtuple
27 27 import json
28 28 import logging
29 29 import datetime
30 30 import urllib
31 31
32 32 from pylons.i18n.translation import _
33 33 from pylons.i18n.translation import lazy_ugettext
34 34 from sqlalchemy import or_
35 35
36 36 from rhodecode.lib import helpers as h, hooks_utils, diffs
37 37 from rhodecode.lib.compat import OrderedDict
38 38 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
39 39 from rhodecode.lib.markup_renderer import (
40 40 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
41 41 from rhodecode.lib.utils import action_logger
42 42 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
43 43 from rhodecode.lib.vcs.backends.base import (
44 44 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
45 45 from rhodecode.lib.vcs.conf import settings as vcs_settings
46 46 from rhodecode.lib.vcs.exceptions import (
47 47 CommitDoesNotExistError, EmptyRepositoryError)
48 48 from rhodecode.model import BaseModel
49 49 from rhodecode.model.changeset_status import ChangesetStatusModel
50 from rhodecode.model.comment import ChangesetCommentsModel
50 from rhodecode.model.comment import CommentsModel
51 51 from rhodecode.model.db import (
52 52 PullRequest, PullRequestReviewers, ChangesetStatus,
53 53 PullRequestVersion, ChangesetComment)
54 54 from rhodecode.model.meta import Session
55 55 from rhodecode.model.notification import NotificationModel, \
56 56 EmailNotificationModel
57 57 from rhodecode.model.scm import ScmModel
58 58 from rhodecode.model.settings import VcsSettingsModel
59 59
60 60
61 61 log = logging.getLogger(__name__)
62 62
63 63
64 64 # Data structure to hold the response data when updating commits during a pull
65 65 # request update.
66 66 UpdateResponse = namedtuple(
67 67 'UpdateResponse', 'executed, reason, new, old, changes')
68 68
69 69
70 70 class PullRequestModel(BaseModel):
71 71
72 72 cls = PullRequest
73 73
74 74 DIFF_CONTEXT = 3
75 75
76 76 MERGE_STATUS_MESSAGES = {
77 77 MergeFailureReason.NONE: lazy_ugettext(
78 78 'This pull request can be automatically merged.'),
79 79 MergeFailureReason.UNKNOWN: lazy_ugettext(
80 80 'This pull request cannot be merged because of an unhandled'
81 81 ' exception.'),
82 82 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
83 83 'This pull request cannot be merged because of conflicts.'),
84 84 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
85 85 'This pull request could not be merged because push to target'
86 86 ' failed.'),
87 87 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
88 88 'This pull request cannot be merged because the target is not a'
89 89 ' head.'),
90 90 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
91 91 'This pull request cannot be merged because the source contains'
92 92 ' more branches than the target.'),
93 93 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
94 94 'This pull request cannot be merged because the target has'
95 95 ' multiple heads.'),
96 96 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
97 97 'This pull request cannot be merged because the target repository'
98 98 ' is locked.'),
99 99 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
100 100 'This pull request cannot be merged because the target or the '
101 101 'source reference is missing.'),
102 102 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
103 103 'This pull request cannot be merged because the target '
104 104 'reference is missing.'),
105 105 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
106 106 'This pull request cannot be merged because the source '
107 107 'reference is missing.'),
108 108 MergeFailureReason.SUBREPO_MERGE_FAILED: lazy_ugettext(
109 109 'This pull request cannot be merged because of conflicts related '
110 110 'to sub repositories.'),
111 111 }
112 112
113 113 UPDATE_STATUS_MESSAGES = {
114 114 UpdateFailureReason.NONE: lazy_ugettext(
115 115 'Pull request update successful.'),
116 116 UpdateFailureReason.UNKNOWN: lazy_ugettext(
117 117 'Pull request update failed because of an unknown error.'),
118 118 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
119 119 'No update needed because the source reference is already '
120 120 'up to date.'),
121 121 UpdateFailureReason.WRONG_REF_TPYE: lazy_ugettext(
122 122 'Pull request cannot be updated because the reference type is '
123 123 'not supported for an update.'),
124 124 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
125 125 'This pull request cannot be updated because the target '
126 126 'reference is missing.'),
127 127 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
128 128 'This pull request cannot be updated because the source '
129 129 'reference is missing.'),
130 130 }
131 131
132 132 def __get_pull_request(self, pull_request):
133 133 return self._get_instance((
134 134 PullRequest, PullRequestVersion), pull_request)
135 135
136 136 def _check_perms(self, perms, pull_request, user, api=False):
137 137 if not api:
138 138 return h.HasRepoPermissionAny(*perms)(
139 139 user=user, repo_name=pull_request.target_repo.repo_name)
140 140 else:
141 141 return h.HasRepoPermissionAnyApi(*perms)(
142 142 user=user, repo_name=pull_request.target_repo.repo_name)
143 143
144 144 def check_user_read(self, pull_request, user, api=False):
145 145 _perms = ('repository.admin', 'repository.write', 'repository.read',)
146 146 return self._check_perms(_perms, pull_request, user, api)
147 147
148 148 def check_user_merge(self, pull_request, user, api=False):
149 149 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
150 150 return self._check_perms(_perms, pull_request, user, api)
151 151
152 152 def check_user_update(self, pull_request, user, api=False):
153 153 owner = user.user_id == pull_request.user_id
154 154 return self.check_user_merge(pull_request, user, api) or owner
155 155
156 156 def check_user_delete(self, pull_request, user):
157 157 owner = user.user_id == pull_request.user_id
158 158 _perms = ('repository.admin')
159 159 return self._check_perms(_perms, pull_request, user) or owner
160 160
161 161 def check_user_change_status(self, pull_request, user, api=False):
162 162 reviewer = user.user_id in [x.user_id for x in
163 163 pull_request.reviewers]
164 164 return self.check_user_update(pull_request, user, api) or reviewer
165 165
166 166 def get(self, pull_request):
167 167 return self.__get_pull_request(pull_request)
168 168
169 169 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
170 170 opened_by=None, order_by=None,
171 171 order_dir='desc'):
172 172 repo = None
173 173 if repo_name:
174 174 repo = self._get_repo(repo_name)
175 175
176 176 q = PullRequest.query()
177 177
178 178 # source or target
179 179 if repo and source:
180 180 q = q.filter(PullRequest.source_repo == repo)
181 181 elif repo:
182 182 q = q.filter(PullRequest.target_repo == repo)
183 183
184 184 # closed,opened
185 185 if statuses:
186 186 q = q.filter(PullRequest.status.in_(statuses))
187 187
188 188 # opened by filter
189 189 if opened_by:
190 190 q = q.filter(PullRequest.user_id.in_(opened_by))
191 191
192 192 if order_by:
193 193 order_map = {
194 194 'name_raw': PullRequest.pull_request_id,
195 195 'title': PullRequest.title,
196 196 'updated_on_raw': PullRequest.updated_on,
197 197 'target_repo': PullRequest.target_repo_id
198 198 }
199 199 if order_dir == 'asc':
200 200 q = q.order_by(order_map[order_by].asc())
201 201 else:
202 202 q = q.order_by(order_map[order_by].desc())
203 203
204 204 return q
205 205
206 206 def count_all(self, repo_name, source=False, statuses=None,
207 207 opened_by=None):
208 208 """
209 209 Count the number of pull requests for a specific repository.
210 210
211 211 :param repo_name: target or source repo
212 212 :param source: boolean flag to specify if repo_name refers to source
213 213 :param statuses: list of pull request statuses
214 214 :param opened_by: author user of the pull request
215 215 :returns: int number of pull requests
216 216 """
217 217 q = self._prepare_get_all_query(
218 218 repo_name, source=source, statuses=statuses, opened_by=opened_by)
219 219
220 220 return q.count()
221 221
222 222 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
223 223 offset=0, length=None, order_by=None, order_dir='desc'):
224 224 """
225 225 Get all pull requests for a specific repository.
226 226
227 227 :param repo_name: target or source repo
228 228 :param source: boolean flag to specify if repo_name refers to source
229 229 :param statuses: list of pull request statuses
230 230 :param opened_by: author user of the pull request
231 231 :param offset: pagination offset
232 232 :param length: length of returned list
233 233 :param order_by: order of the returned list
234 234 :param order_dir: 'asc' or 'desc' ordering direction
235 235 :returns: list of pull requests
236 236 """
237 237 q = self._prepare_get_all_query(
238 238 repo_name, source=source, statuses=statuses, opened_by=opened_by,
239 239 order_by=order_by, order_dir=order_dir)
240 240
241 241 if length:
242 242 pull_requests = q.limit(length).offset(offset).all()
243 243 else:
244 244 pull_requests = q.all()
245 245
246 246 return pull_requests
247 247
248 248 def count_awaiting_review(self, repo_name, source=False, statuses=None,
249 249 opened_by=None):
250 250 """
251 251 Count the number of pull requests for a specific repository that are
252 252 awaiting review.
253 253
254 254 :param repo_name: target or source repo
255 255 :param source: boolean flag to specify if repo_name refers to source
256 256 :param statuses: list of pull request statuses
257 257 :param opened_by: author user of the pull request
258 258 :returns: int number of pull requests
259 259 """
260 260 pull_requests = self.get_awaiting_review(
261 261 repo_name, source=source, statuses=statuses, opened_by=opened_by)
262 262
263 263 return len(pull_requests)
264 264
265 265 def get_awaiting_review(self, repo_name, source=False, statuses=None,
266 266 opened_by=None, offset=0, length=None,
267 267 order_by=None, order_dir='desc'):
268 268 """
269 269 Get all pull requests for a specific repository that are awaiting
270 270 review.
271 271
272 272 :param repo_name: target or source repo
273 273 :param source: boolean flag to specify if repo_name refers to source
274 274 :param statuses: list of pull request statuses
275 275 :param opened_by: author user of the pull request
276 276 :param offset: pagination offset
277 277 :param length: length of returned list
278 278 :param order_by: order of the returned list
279 279 :param order_dir: 'asc' or 'desc' ordering direction
280 280 :returns: list of pull requests
281 281 """
282 282 pull_requests = self.get_all(
283 283 repo_name, source=source, statuses=statuses, opened_by=opened_by,
284 284 order_by=order_by, order_dir=order_dir)
285 285
286 286 _filtered_pull_requests = []
287 287 for pr in pull_requests:
288 288 status = pr.calculated_review_status()
289 289 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
290 290 ChangesetStatus.STATUS_UNDER_REVIEW]:
291 291 _filtered_pull_requests.append(pr)
292 292 if length:
293 293 return _filtered_pull_requests[offset:offset+length]
294 294 else:
295 295 return _filtered_pull_requests
296 296
297 297 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
298 298 opened_by=None, user_id=None):
299 299 """
300 300 Count the number of pull requests for a specific repository that are
301 301 awaiting review from a specific user.
302 302
303 303 :param repo_name: target or source repo
304 304 :param source: boolean flag to specify if repo_name refers to source
305 305 :param statuses: list of pull request statuses
306 306 :param opened_by: author user of the pull request
307 307 :param user_id: reviewer user of the pull request
308 308 :returns: int number of pull requests
309 309 """
310 310 pull_requests = self.get_awaiting_my_review(
311 311 repo_name, source=source, statuses=statuses, opened_by=opened_by,
312 312 user_id=user_id)
313 313
314 314 return len(pull_requests)
315 315
316 316 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
317 317 opened_by=None, user_id=None, offset=0,
318 318 length=None, order_by=None, order_dir='desc'):
319 319 """
320 320 Get all pull requests for a specific repository that are awaiting
321 321 review from a specific user.
322 322
323 323 :param repo_name: target or source repo
324 324 :param source: boolean flag to specify if repo_name refers to source
325 325 :param statuses: list of pull request statuses
326 326 :param opened_by: author user of the pull request
327 327 :param user_id: reviewer user of the pull request
328 328 :param offset: pagination offset
329 329 :param length: length of returned list
330 330 :param order_by: order of the returned list
331 331 :param order_dir: 'asc' or 'desc' ordering direction
332 332 :returns: list of pull requests
333 333 """
334 334 pull_requests = self.get_all(
335 335 repo_name, source=source, statuses=statuses, opened_by=opened_by,
336 336 order_by=order_by, order_dir=order_dir)
337 337
338 338 _my = PullRequestModel().get_not_reviewed(user_id)
339 339 my_participation = []
340 340 for pr in pull_requests:
341 341 if pr in _my:
342 342 my_participation.append(pr)
343 343 _filtered_pull_requests = my_participation
344 344 if length:
345 345 return _filtered_pull_requests[offset:offset+length]
346 346 else:
347 347 return _filtered_pull_requests
348 348
349 349 def get_not_reviewed(self, user_id):
350 350 return [
351 351 x.pull_request for x in PullRequestReviewers.query().filter(
352 352 PullRequestReviewers.user_id == user_id).all()
353 353 ]
354 354
355 355 def _prepare_participating_query(self, user_id=None, statuses=None,
356 356 order_by=None, order_dir='desc'):
357 357 q = PullRequest.query()
358 358 if user_id:
359 359 reviewers_subquery = Session().query(
360 360 PullRequestReviewers.pull_request_id).filter(
361 361 PullRequestReviewers.user_id == user_id).subquery()
362 362 user_filter= or_(
363 363 PullRequest.user_id == user_id,
364 364 PullRequest.pull_request_id.in_(reviewers_subquery)
365 365 )
366 366 q = PullRequest.query().filter(user_filter)
367 367
368 368 # closed,opened
369 369 if statuses:
370 370 q = q.filter(PullRequest.status.in_(statuses))
371 371
372 372 if order_by:
373 373 order_map = {
374 374 'name_raw': PullRequest.pull_request_id,
375 375 'title': PullRequest.title,
376 376 'updated_on_raw': PullRequest.updated_on,
377 377 'target_repo': PullRequest.target_repo_id
378 378 }
379 379 if order_dir == 'asc':
380 380 q = q.order_by(order_map[order_by].asc())
381 381 else:
382 382 q = q.order_by(order_map[order_by].desc())
383 383
384 384 return q
385 385
386 386 def count_im_participating_in(self, user_id=None, statuses=None):
387 387 q = self._prepare_participating_query(user_id, statuses=statuses)
388 388 return q.count()
389 389
390 390 def get_im_participating_in(
391 391 self, user_id=None, statuses=None, offset=0,
392 392 length=None, order_by=None, order_dir='desc'):
393 393 """
394 394 Get all Pull requests that i'm participating in, or i have opened
395 395 """
396 396
397 397 q = self._prepare_participating_query(
398 398 user_id, statuses=statuses, order_by=order_by,
399 399 order_dir=order_dir)
400 400
401 401 if length:
402 402 pull_requests = q.limit(length).offset(offset).all()
403 403 else:
404 404 pull_requests = q.all()
405 405
406 406 return pull_requests
407 407
408 408 def get_versions(self, pull_request):
409 409 """
410 410 returns version of pull request sorted by ID descending
411 411 """
412 412 return PullRequestVersion.query()\
413 413 .filter(PullRequestVersion.pull_request == pull_request)\
414 414 .order_by(PullRequestVersion.pull_request_version_id.asc())\
415 415 .all()
416 416
417 417 def create(self, created_by, source_repo, source_ref, target_repo,
418 418 target_ref, revisions, reviewers, title, description=None):
419 419 created_by_user = self._get_user(created_by)
420 420 source_repo = self._get_repo(source_repo)
421 421 target_repo = self._get_repo(target_repo)
422 422
423 423 pull_request = PullRequest()
424 424 pull_request.source_repo = source_repo
425 425 pull_request.source_ref = source_ref
426 426 pull_request.target_repo = target_repo
427 427 pull_request.target_ref = target_ref
428 428 pull_request.revisions = revisions
429 429 pull_request.title = title
430 430 pull_request.description = description
431 431 pull_request.author = created_by_user
432 432
433 433 Session().add(pull_request)
434 434 Session().flush()
435 435
436 436 reviewer_ids = set()
437 437 # members / reviewers
438 438 for reviewer_object in reviewers:
439 439 if isinstance(reviewer_object, tuple):
440 440 user_id, reasons = reviewer_object
441 441 else:
442 442 user_id, reasons = reviewer_object, []
443 443
444 444 user = self._get_user(user_id)
445 445 reviewer_ids.add(user.user_id)
446 446
447 447 reviewer = PullRequestReviewers(user, pull_request, reasons)
448 448 Session().add(reviewer)
449 449
450 450 # Set approval status to "Under Review" for all commits which are
451 451 # part of this pull request.
452 452 ChangesetStatusModel().set_status(
453 453 repo=target_repo,
454 454 status=ChangesetStatus.STATUS_UNDER_REVIEW,
455 455 user=created_by_user,
456 456 pull_request=pull_request
457 457 )
458 458
459 459 self.notify_reviewers(pull_request, reviewer_ids)
460 460 self._trigger_pull_request_hook(
461 461 pull_request, created_by_user, 'create')
462 462
463 463 return pull_request
464 464
465 465 def _trigger_pull_request_hook(self, pull_request, user, action):
466 466 pull_request = self.__get_pull_request(pull_request)
467 467 target_scm = pull_request.target_repo.scm_instance()
468 468 if action == 'create':
469 469 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
470 470 elif action == 'merge':
471 471 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
472 472 elif action == 'close':
473 473 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
474 474 elif action == 'review_status_change':
475 475 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
476 476 elif action == 'update':
477 477 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
478 478 else:
479 479 return
480 480
481 481 trigger_hook(
482 482 username=user.username,
483 483 repo_name=pull_request.target_repo.repo_name,
484 484 repo_alias=target_scm.alias,
485 485 pull_request=pull_request)
486 486
487 487 def _get_commit_ids(self, pull_request):
488 488 """
489 489 Return the commit ids of the merged pull request.
490 490
491 491 This method is not dealing correctly yet with the lack of autoupdates
492 492 nor with the implicit target updates.
493 493 For example: if a commit in the source repo is already in the target it
494 494 will be reported anyways.
495 495 """
496 496 merge_rev = pull_request.merge_rev
497 497 if merge_rev is None:
498 498 raise ValueError('This pull request was not merged yet')
499 499
500 500 commit_ids = list(pull_request.revisions)
501 501 if merge_rev not in commit_ids:
502 502 commit_ids.append(merge_rev)
503 503
504 504 return commit_ids
505 505
506 506 def merge(self, pull_request, user, extras):
507 507 log.debug("Merging pull request %s", pull_request.pull_request_id)
508 508 merge_state = self._merge_pull_request(pull_request, user, extras)
509 509 if merge_state.executed:
510 510 log.debug(
511 511 "Merge was successful, updating the pull request comments.")
512 512 self._comment_and_close_pr(pull_request, user, merge_state)
513 513 self._log_action('user_merged_pull_request', user, pull_request)
514 514 else:
515 515 log.warn("Merge failed, not updating the pull request.")
516 516 return merge_state
517 517
518 518 def _merge_pull_request(self, pull_request, user, extras):
519 519 target_vcs = pull_request.target_repo.scm_instance()
520 520 source_vcs = pull_request.source_repo.scm_instance()
521 521 target_ref = self._refresh_reference(
522 522 pull_request.target_ref_parts, target_vcs)
523 523
524 524 message = _(
525 525 'Merge pull request #%(pr_id)s from '
526 526 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
527 527 'pr_id': pull_request.pull_request_id,
528 528 'source_repo': source_vcs.name,
529 529 'source_ref_name': pull_request.source_ref_parts.name,
530 530 'pr_title': pull_request.title
531 531 }
532 532
533 533 workspace_id = self._workspace_id(pull_request)
534 534 use_rebase = self._use_rebase_for_merging(pull_request)
535 535
536 536 callback_daemon, extras = prepare_callback_daemon(
537 537 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
538 538 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
539 539
540 540 with callback_daemon:
541 541 # TODO: johbo: Implement a clean way to run a config_override
542 542 # for a single call.
543 543 target_vcs.config.set(
544 544 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
545 545 merge_state = target_vcs.merge(
546 546 target_ref, source_vcs, pull_request.source_ref_parts,
547 547 workspace_id, user_name=user.username,
548 548 user_email=user.email, message=message, use_rebase=use_rebase)
549 549 return merge_state
550 550
551 551 def _comment_and_close_pr(self, pull_request, user, merge_state):
552 552 pull_request.merge_rev = merge_state.merge_ref.commit_id
553 553 pull_request.updated_on = datetime.datetime.now()
554 554
555 ChangesetCommentsModel().create(
555 CommentsModel().create(
556 556 text=unicode(_('Pull request merged and closed')),
557 557 repo=pull_request.target_repo.repo_id,
558 558 user=user.user_id,
559 559 pull_request=pull_request.pull_request_id,
560 560 f_path=None,
561 561 line_no=None,
562 562 closing_pr=True
563 563 )
564 564
565 565 Session().add(pull_request)
566 566 Session().flush()
567 567 # TODO: paris: replace invalidation with less radical solution
568 568 ScmModel().mark_for_invalidation(
569 569 pull_request.target_repo.repo_name)
570 570 self._trigger_pull_request_hook(pull_request, user, 'merge')
571 571
572 572 def has_valid_update_type(self, pull_request):
573 573 source_ref_type = pull_request.source_ref_parts.type
574 574 return source_ref_type in ['book', 'branch', 'tag']
575 575
576 576 def update_commits(self, pull_request):
577 577 """
578 578 Get the updated list of commits for the pull request
579 579 and return the new pull request version and the list
580 580 of commits processed by this update action
581 581 """
582 582 pull_request = self.__get_pull_request(pull_request)
583 583 source_ref_type = pull_request.source_ref_parts.type
584 584 source_ref_name = pull_request.source_ref_parts.name
585 585 source_ref_id = pull_request.source_ref_parts.commit_id
586 586
587 587 if not self.has_valid_update_type(pull_request):
588 588 log.debug(
589 589 "Skipping update of pull request %s due to ref type: %s",
590 590 pull_request, source_ref_type)
591 591 return UpdateResponse(
592 592 executed=False,
593 593 reason=UpdateFailureReason.WRONG_REF_TPYE,
594 594 old=pull_request, new=None, changes=None)
595 595
596 596 source_repo = pull_request.source_repo.scm_instance()
597 597 try:
598 598 source_commit = source_repo.get_commit(commit_id=source_ref_name)
599 599 except CommitDoesNotExistError:
600 600 return UpdateResponse(
601 601 executed=False,
602 602 reason=UpdateFailureReason.MISSING_SOURCE_REF,
603 603 old=pull_request, new=None, changes=None)
604 604
605 605 if source_ref_id == source_commit.raw_id:
606 606 log.debug("Nothing changed in pull request %s", pull_request)
607 607 return UpdateResponse(
608 608 executed=False,
609 609 reason=UpdateFailureReason.NO_CHANGE,
610 610 old=pull_request, new=None, changes=None)
611 611
612 612 # Finally there is a need for an update
613 613 pull_request_version = self._create_version_from_snapshot(pull_request)
614 614 self._link_comments_to_version(pull_request_version)
615 615
616 616 target_ref_type = pull_request.target_ref_parts.type
617 617 target_ref_name = pull_request.target_ref_parts.name
618 618 target_ref_id = pull_request.target_ref_parts.commit_id
619 619 target_repo = pull_request.target_repo.scm_instance()
620 620
621 621 try:
622 622 if target_ref_type in ('tag', 'branch', 'book'):
623 623 target_commit = target_repo.get_commit(target_ref_name)
624 624 else:
625 625 target_commit = target_repo.get_commit(target_ref_id)
626 626 except CommitDoesNotExistError:
627 627 return UpdateResponse(
628 628 executed=False,
629 629 reason=UpdateFailureReason.MISSING_TARGET_REF,
630 630 old=pull_request, new=None, changes=None)
631 631
632 632 # re-compute commit ids
633 633 old_commit_ids = set(pull_request.revisions)
634 634 pre_load = ["author", "branch", "date", "message"]
635 635 commit_ranges = target_repo.compare(
636 636 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
637 637 pre_load=pre_load)
638 638
639 639 ancestor = target_repo.get_common_ancestor(
640 640 target_commit.raw_id, source_commit.raw_id, source_repo)
641 641
642 642 pull_request.source_ref = '%s:%s:%s' % (
643 643 source_ref_type, source_ref_name, source_commit.raw_id)
644 644 pull_request.target_ref = '%s:%s:%s' % (
645 645 target_ref_type, target_ref_name, ancestor)
646 646 pull_request.revisions = [
647 647 commit.raw_id for commit in reversed(commit_ranges)]
648 648 pull_request.updated_on = datetime.datetime.now()
649 649 Session().add(pull_request)
650 650 new_commit_ids = set(pull_request.revisions)
651 651
652 652 changes = self._calculate_commit_id_changes(
653 653 old_commit_ids, new_commit_ids)
654 654
655 655 old_diff_data, new_diff_data = self._generate_update_diffs(
656 656 pull_request, pull_request_version)
657 657
658 ChangesetCommentsModel().outdate_comments(
658 CommentsModel().outdate_comments(
659 659 pull_request, old_diff_data=old_diff_data,
660 660 new_diff_data=new_diff_data)
661 661
662 662 file_changes = self._calculate_file_changes(
663 663 old_diff_data, new_diff_data)
664 664
665 665 # Add an automatic comment to the pull request
666 update_comment = ChangesetCommentsModel().create(
666 update_comment = CommentsModel().create(
667 667 text=self._render_update_message(changes, file_changes),
668 668 repo=pull_request.target_repo,
669 669 user=pull_request.author,
670 670 pull_request=pull_request,
671 671 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
672 672
673 673 # Update status to "Under Review" for added commits
674 674 for commit_id in changes.added:
675 675 ChangesetStatusModel().set_status(
676 676 repo=pull_request.source_repo,
677 677 status=ChangesetStatus.STATUS_UNDER_REVIEW,
678 678 comment=update_comment,
679 679 user=pull_request.author,
680 680 pull_request=pull_request,
681 681 revision=commit_id)
682 682
683 683 log.debug(
684 684 'Updated pull request %s, added_ids: %s, common_ids: %s, '
685 685 'removed_ids: %s', pull_request.pull_request_id,
686 686 changes.added, changes.common, changes.removed)
687 687 log.debug('Updated pull request with the following file changes: %s',
688 688 file_changes)
689 689
690 690 log.info(
691 691 "Updated pull request %s from commit %s to commit %s, "
692 692 "stored new version %s of this pull request.",
693 693 pull_request.pull_request_id, source_ref_id,
694 694 pull_request.source_ref_parts.commit_id,
695 695 pull_request_version.pull_request_version_id)
696 696 Session().commit()
697 697 self._trigger_pull_request_hook(pull_request, pull_request.author,
698 698 'update')
699 699
700 700 return UpdateResponse(
701 701 executed=True, reason=UpdateFailureReason.NONE,
702 702 old=pull_request, new=pull_request_version, changes=changes)
703 703
704 704 def _create_version_from_snapshot(self, pull_request):
705 705 version = PullRequestVersion()
706 706 version.title = pull_request.title
707 707 version.description = pull_request.description
708 708 version.status = pull_request.status
709 709 version.created_on = datetime.datetime.now()
710 710 version.updated_on = pull_request.updated_on
711 711 version.user_id = pull_request.user_id
712 712 version.source_repo = pull_request.source_repo
713 713 version.source_ref = pull_request.source_ref
714 714 version.target_repo = pull_request.target_repo
715 715 version.target_ref = pull_request.target_ref
716 716
717 717 version._last_merge_source_rev = pull_request._last_merge_source_rev
718 718 version._last_merge_target_rev = pull_request._last_merge_target_rev
719 719 version._last_merge_status = pull_request._last_merge_status
720 720 version.shadow_merge_ref = pull_request.shadow_merge_ref
721 721 version.merge_rev = pull_request.merge_rev
722 722
723 723 version.revisions = pull_request.revisions
724 724 version.pull_request = pull_request
725 725 Session().add(version)
726 726 Session().flush()
727 727
728 728 return version
729 729
730 730 def _generate_update_diffs(self, pull_request, pull_request_version):
731 731 diff_context = (
732 732 self.DIFF_CONTEXT +
733 ChangesetCommentsModel.needed_extra_diff_context())
733 CommentsModel.needed_extra_diff_context())
734 734 old_diff = self._get_diff_from_pr_or_version(
735 735 pull_request_version, context=diff_context)
736 736 new_diff = self._get_diff_from_pr_or_version(
737 737 pull_request, context=diff_context)
738 738
739 739 old_diff_data = diffs.DiffProcessor(old_diff)
740 740 old_diff_data.prepare()
741 741 new_diff_data = diffs.DiffProcessor(new_diff)
742 742 new_diff_data.prepare()
743 743
744 744 return old_diff_data, new_diff_data
745 745
746 746 def _link_comments_to_version(self, pull_request_version):
747 747 """
748 748 Link all unlinked comments of this pull request to the given version.
749 749
750 750 :param pull_request_version: The `PullRequestVersion` to which
751 751 the comments shall be linked.
752 752
753 753 """
754 754 pull_request = pull_request_version.pull_request
755 755 comments = ChangesetComment.query().filter(
756 756 # TODO: johbo: Should we query for the repo at all here?
757 757 # Pending decision on how comments of PRs are to be related
758 758 # to either the source repo, the target repo or no repo at all.
759 759 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
760 760 ChangesetComment.pull_request == pull_request,
761 761 ChangesetComment.pull_request_version == None)
762 762
763 763 # TODO: johbo: Find out why this breaks if it is done in a bulk
764 764 # operation.
765 765 for comment in comments:
766 766 comment.pull_request_version_id = (
767 767 pull_request_version.pull_request_version_id)
768 768 Session().add(comment)
769 769
770 770 def _calculate_commit_id_changes(self, old_ids, new_ids):
771 771 added = new_ids.difference(old_ids)
772 772 common = old_ids.intersection(new_ids)
773 773 removed = old_ids.difference(new_ids)
774 774 return ChangeTuple(added, common, removed)
775 775
776 776 def _calculate_file_changes(self, old_diff_data, new_diff_data):
777 777
778 778 old_files = OrderedDict()
779 779 for diff_data in old_diff_data.parsed_diff:
780 780 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
781 781
782 782 added_files = []
783 783 modified_files = []
784 784 removed_files = []
785 785 for diff_data in new_diff_data.parsed_diff:
786 786 new_filename = diff_data['filename']
787 787 new_hash = md5_safe(diff_data['raw_diff'])
788 788
789 789 old_hash = old_files.get(new_filename)
790 790 if not old_hash:
791 791 # file is not present in old diff, means it's added
792 792 added_files.append(new_filename)
793 793 else:
794 794 if new_hash != old_hash:
795 795 modified_files.append(new_filename)
796 796 # now remove a file from old, since we have seen it already
797 797 del old_files[new_filename]
798 798
799 799 # removed files is when there are present in old, but not in NEW,
800 800 # since we remove old files that are present in new diff, left-overs
801 801 # if any should be the removed files
802 802 removed_files.extend(old_files.keys())
803 803
804 804 return FileChangeTuple(added_files, modified_files, removed_files)
805 805
806 806 def _render_update_message(self, changes, file_changes):
807 807 """
808 808 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
809 809 so it's always looking the same disregarding on which default
810 810 renderer system is using.
811 811
812 812 :param changes: changes named tuple
813 813 :param file_changes: file changes named tuple
814 814
815 815 """
816 816 new_status = ChangesetStatus.get_status_lbl(
817 817 ChangesetStatus.STATUS_UNDER_REVIEW)
818 818
819 819 changed_files = (
820 820 file_changes.added + file_changes.modified + file_changes.removed)
821 821
822 822 params = {
823 823 'under_review_label': new_status,
824 824 'added_commits': changes.added,
825 825 'removed_commits': changes.removed,
826 826 'changed_files': changed_files,
827 827 'added_files': file_changes.added,
828 828 'modified_files': file_changes.modified,
829 829 'removed_files': file_changes.removed,
830 830 }
831 831 renderer = RstTemplateRenderer()
832 832 return renderer.render('pull_request_update.mako', **params)
833 833
834 834 def edit(self, pull_request, title, description):
835 835 pull_request = self.__get_pull_request(pull_request)
836 836 if pull_request.is_closed():
837 837 raise ValueError('This pull request is closed')
838 838 if title:
839 839 pull_request.title = title
840 840 pull_request.description = description
841 841 pull_request.updated_on = datetime.datetime.now()
842 842 Session().add(pull_request)
843 843
844 844 def update_reviewers(self, pull_request, reviewer_data):
845 845 """
846 846 Update the reviewers in the pull request
847 847
848 848 :param pull_request: the pr to update
849 849 :param reviewer_data: list of tuples [(user, ['reason1', 'reason2'])]
850 850 """
851 851
852 852 reviewers_reasons = {}
853 853 for user_id, reasons in reviewer_data:
854 854 if isinstance(user_id, (int, basestring)):
855 855 user_id = self._get_user(user_id).user_id
856 856 reviewers_reasons[user_id] = reasons
857 857
858 858 reviewers_ids = set(reviewers_reasons.keys())
859 859 pull_request = self.__get_pull_request(pull_request)
860 860 current_reviewers = PullRequestReviewers.query()\
861 861 .filter(PullRequestReviewers.pull_request ==
862 862 pull_request).all()
863 863 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
864 864
865 865 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
866 866 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
867 867
868 868 log.debug("Adding %s reviewers", ids_to_add)
869 869 log.debug("Removing %s reviewers", ids_to_remove)
870 870 changed = False
871 871 for uid in ids_to_add:
872 872 changed = True
873 873 _usr = self._get_user(uid)
874 874 reasons = reviewers_reasons[uid]
875 875 reviewer = PullRequestReviewers(_usr, pull_request, reasons)
876 876 Session().add(reviewer)
877 877
878 878 self.notify_reviewers(pull_request, ids_to_add)
879 879
880 880 for uid in ids_to_remove:
881 881 changed = True
882 882 reviewer = PullRequestReviewers.query()\
883 883 .filter(PullRequestReviewers.user_id == uid,
884 884 PullRequestReviewers.pull_request == pull_request)\
885 885 .scalar()
886 886 if reviewer:
887 887 Session().delete(reviewer)
888 888 if changed:
889 889 pull_request.updated_on = datetime.datetime.now()
890 890 Session().add(pull_request)
891 891
892 892 return ids_to_add, ids_to_remove
893 893
894 894 def get_url(self, pull_request):
895 895 return h.url('pullrequest_show',
896 896 repo_name=safe_str(pull_request.target_repo.repo_name),
897 897 pull_request_id=pull_request.pull_request_id,
898 898 qualified=True)
899 899
900 900 def get_shadow_clone_url(self, pull_request):
901 901 """
902 902 Returns qualified url pointing to the shadow repository. If this pull
903 903 request is closed there is no shadow repository and ``None`` will be
904 904 returned.
905 905 """
906 906 if pull_request.is_closed():
907 907 return None
908 908 else:
909 909 pr_url = urllib.unquote(self.get_url(pull_request))
910 910 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
911 911
912 912 def notify_reviewers(self, pull_request, reviewers_ids):
913 913 # notification to reviewers
914 914 if not reviewers_ids:
915 915 return
916 916
917 917 pull_request_obj = pull_request
918 918 # get the current participants of this pull request
919 919 recipients = reviewers_ids
920 920 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
921 921
922 922 pr_source_repo = pull_request_obj.source_repo
923 923 pr_target_repo = pull_request_obj.target_repo
924 924
925 925 pr_url = h.url(
926 926 'pullrequest_show',
927 927 repo_name=pr_target_repo.repo_name,
928 928 pull_request_id=pull_request_obj.pull_request_id,
929 929 qualified=True,)
930 930
931 931 # set some variables for email notification
932 932 pr_target_repo_url = h.url(
933 933 'summary_home',
934 934 repo_name=pr_target_repo.repo_name,
935 935 qualified=True)
936 936
937 937 pr_source_repo_url = h.url(
938 938 'summary_home',
939 939 repo_name=pr_source_repo.repo_name,
940 940 qualified=True)
941 941
942 942 # pull request specifics
943 943 pull_request_commits = [
944 944 (x.raw_id, x.message)
945 945 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
946 946
947 947 kwargs = {
948 948 'user': pull_request.author,
949 949 'pull_request': pull_request_obj,
950 950 'pull_request_commits': pull_request_commits,
951 951
952 952 'pull_request_target_repo': pr_target_repo,
953 953 'pull_request_target_repo_url': pr_target_repo_url,
954 954
955 955 'pull_request_source_repo': pr_source_repo,
956 956 'pull_request_source_repo_url': pr_source_repo_url,
957 957
958 958 'pull_request_url': pr_url,
959 959 }
960 960
961 961 # pre-generate the subject for notification itself
962 962 (subject,
963 963 _h, _e, # we don't care about those
964 964 body_plaintext) = EmailNotificationModel().render_email(
965 965 notification_type, **kwargs)
966 966
967 967 # create notification objects, and emails
968 968 NotificationModel().create(
969 969 created_by=pull_request.author,
970 970 notification_subject=subject,
971 971 notification_body=body_plaintext,
972 972 notification_type=notification_type,
973 973 recipients=recipients,
974 974 email_kwargs=kwargs,
975 975 )
976 976
977 977 def delete(self, pull_request):
978 978 pull_request = self.__get_pull_request(pull_request)
979 979 self._cleanup_merge_workspace(pull_request)
980 980 Session().delete(pull_request)
981 981
982 982 def close_pull_request(self, pull_request, user):
983 983 pull_request = self.__get_pull_request(pull_request)
984 984 self._cleanup_merge_workspace(pull_request)
985 985 pull_request.status = PullRequest.STATUS_CLOSED
986 986 pull_request.updated_on = datetime.datetime.now()
987 987 Session().add(pull_request)
988 988 self._trigger_pull_request_hook(
989 989 pull_request, pull_request.author, 'close')
990 990 self._log_action('user_closed_pull_request', user, pull_request)
991 991
992 992 def close_pull_request_with_comment(self, pull_request, user, repo,
993 993 message=None):
994 994 status = ChangesetStatus.STATUS_REJECTED
995 995
996 996 if not message:
997 997 message = (
998 998 _('Status change %(transition_icon)s %(status)s') % {
999 999 'transition_icon': '>',
1000 1000 'status': ChangesetStatus.get_status_lbl(status)})
1001 1001
1002 1002 internal_message = _('Closing with') + ' ' + message
1003 1003
1004 comm = ChangesetCommentsModel().create(
1004 comm = CommentsModel().create(
1005 1005 text=internal_message,
1006 1006 repo=repo.repo_id,
1007 1007 user=user.user_id,
1008 1008 pull_request=pull_request.pull_request_id,
1009 1009 f_path=None,
1010 1010 line_no=None,
1011 1011 status_change=ChangesetStatus.get_status_lbl(status),
1012 1012 status_change_type=status,
1013 1013 closing_pr=True
1014 1014 )
1015 1015
1016 1016 ChangesetStatusModel().set_status(
1017 1017 repo.repo_id,
1018 1018 status,
1019 1019 user.user_id,
1020 1020 comm,
1021 1021 pull_request=pull_request.pull_request_id
1022 1022 )
1023 1023 Session().flush()
1024 1024
1025 1025 PullRequestModel().close_pull_request(
1026 1026 pull_request.pull_request_id, user)
1027 1027
1028 1028 def merge_status(self, pull_request):
1029 1029 if not self._is_merge_enabled(pull_request):
1030 1030 return False, _('Server-side pull request merging is disabled.')
1031 1031 if pull_request.is_closed():
1032 1032 return False, _('This pull request is closed.')
1033 1033 merge_possible, msg = self._check_repo_requirements(
1034 1034 target=pull_request.target_repo, source=pull_request.source_repo)
1035 1035 if not merge_possible:
1036 1036 return merge_possible, msg
1037 1037
1038 1038 try:
1039 1039 resp = self._try_merge(pull_request)
1040 1040 log.debug("Merge response: %s", resp)
1041 1041 status = resp.possible, self.merge_status_message(
1042 1042 resp.failure_reason)
1043 1043 except NotImplementedError:
1044 1044 status = False, _('Pull request merging is not supported.')
1045 1045
1046 1046 return status
1047 1047
1048 1048 def _check_repo_requirements(self, target, source):
1049 1049 """
1050 1050 Check if `target` and `source` have compatible requirements.
1051 1051
1052 1052 Currently this is just checking for largefiles.
1053 1053 """
1054 1054 target_has_largefiles = self._has_largefiles(target)
1055 1055 source_has_largefiles = self._has_largefiles(source)
1056 1056 merge_possible = True
1057 1057 message = u''
1058 1058
1059 1059 if target_has_largefiles != source_has_largefiles:
1060 1060 merge_possible = False
1061 1061 if source_has_largefiles:
1062 1062 message = _(
1063 1063 'Target repository large files support is disabled.')
1064 1064 else:
1065 1065 message = _(
1066 1066 'Source repository large files support is disabled.')
1067 1067
1068 1068 return merge_possible, message
1069 1069
1070 1070 def _has_largefiles(self, repo):
1071 1071 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1072 1072 'extensions', 'largefiles')
1073 1073 return largefiles_ui and largefiles_ui[0].active
1074 1074
1075 1075 def _try_merge(self, pull_request):
1076 1076 """
1077 1077 Try to merge the pull request and return the merge status.
1078 1078 """
1079 1079 log.debug(
1080 1080 "Trying out if the pull request %s can be merged.",
1081 1081 pull_request.pull_request_id)
1082 1082 target_vcs = pull_request.target_repo.scm_instance()
1083 1083
1084 1084 # Refresh the target reference.
1085 1085 try:
1086 1086 target_ref = self._refresh_reference(
1087 1087 pull_request.target_ref_parts, target_vcs)
1088 1088 except CommitDoesNotExistError:
1089 1089 merge_state = MergeResponse(
1090 1090 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
1091 1091 return merge_state
1092 1092
1093 1093 target_locked = pull_request.target_repo.locked
1094 1094 if target_locked and target_locked[0]:
1095 1095 log.debug("The target repository is locked.")
1096 1096 merge_state = MergeResponse(
1097 1097 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
1098 1098 elif self._needs_merge_state_refresh(pull_request, target_ref):
1099 1099 log.debug("Refreshing the merge status of the repository.")
1100 1100 merge_state = self._refresh_merge_state(
1101 1101 pull_request, target_vcs, target_ref)
1102 1102 else:
1103 1103 possible = pull_request.\
1104 1104 _last_merge_status == MergeFailureReason.NONE
1105 1105 merge_state = MergeResponse(
1106 1106 possible, False, None, pull_request._last_merge_status)
1107 1107
1108 1108 return merge_state
1109 1109
1110 1110 def _refresh_reference(self, reference, vcs_repository):
1111 1111 if reference.type in ('branch', 'book'):
1112 1112 name_or_id = reference.name
1113 1113 else:
1114 1114 name_or_id = reference.commit_id
1115 1115 refreshed_commit = vcs_repository.get_commit(name_or_id)
1116 1116 refreshed_reference = Reference(
1117 1117 reference.type, reference.name, refreshed_commit.raw_id)
1118 1118 return refreshed_reference
1119 1119
1120 1120 def _needs_merge_state_refresh(self, pull_request, target_reference):
1121 1121 return not(
1122 1122 pull_request.revisions and
1123 1123 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1124 1124 target_reference.commit_id == pull_request._last_merge_target_rev)
1125 1125
1126 1126 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1127 1127 workspace_id = self._workspace_id(pull_request)
1128 1128 source_vcs = pull_request.source_repo.scm_instance()
1129 1129 use_rebase = self._use_rebase_for_merging(pull_request)
1130 1130 merge_state = target_vcs.merge(
1131 1131 target_reference, source_vcs, pull_request.source_ref_parts,
1132 1132 workspace_id, dry_run=True, use_rebase=use_rebase)
1133 1133
1134 1134 # Do not store the response if there was an unknown error.
1135 1135 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1136 1136 pull_request._last_merge_source_rev = \
1137 1137 pull_request.source_ref_parts.commit_id
1138 1138 pull_request._last_merge_target_rev = target_reference.commit_id
1139 1139 pull_request._last_merge_status = merge_state.failure_reason
1140 1140 pull_request.shadow_merge_ref = merge_state.merge_ref
1141 1141 Session().add(pull_request)
1142 1142 Session().commit()
1143 1143
1144 1144 return merge_state
1145 1145
1146 1146 def _workspace_id(self, pull_request):
1147 1147 workspace_id = 'pr-%s' % pull_request.pull_request_id
1148 1148 return workspace_id
1149 1149
1150 1150 def merge_status_message(self, status_code):
1151 1151 """
1152 1152 Return a human friendly error message for the given merge status code.
1153 1153 """
1154 1154 return self.MERGE_STATUS_MESSAGES[status_code]
1155 1155
1156 1156 def generate_repo_data(self, repo, commit_id=None, branch=None,
1157 1157 bookmark=None):
1158 1158 all_refs, selected_ref = \
1159 1159 self._get_repo_pullrequest_sources(
1160 1160 repo.scm_instance(), commit_id=commit_id,
1161 1161 branch=branch, bookmark=bookmark)
1162 1162
1163 1163 refs_select2 = []
1164 1164 for element in all_refs:
1165 1165 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1166 1166 refs_select2.append({'text': element[1], 'children': children})
1167 1167
1168 1168 return {
1169 1169 'user': {
1170 1170 'user_id': repo.user.user_id,
1171 1171 'username': repo.user.username,
1172 1172 'firstname': repo.user.firstname,
1173 1173 'lastname': repo.user.lastname,
1174 1174 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1175 1175 },
1176 1176 'description': h.chop_at_smart(repo.description, '\n'),
1177 1177 'refs': {
1178 1178 'all_refs': all_refs,
1179 1179 'selected_ref': selected_ref,
1180 1180 'select2_refs': refs_select2
1181 1181 }
1182 1182 }
1183 1183
1184 1184 def generate_pullrequest_title(self, source, source_ref, target):
1185 1185 return u'{source}#{at_ref} to {target}'.format(
1186 1186 source=source,
1187 1187 at_ref=source_ref,
1188 1188 target=target,
1189 1189 )
1190 1190
1191 1191 def _cleanup_merge_workspace(self, pull_request):
1192 1192 # Merging related cleanup
1193 1193 target_scm = pull_request.target_repo.scm_instance()
1194 1194 workspace_id = 'pr-%s' % pull_request.pull_request_id
1195 1195
1196 1196 try:
1197 1197 target_scm.cleanup_merge_workspace(workspace_id)
1198 1198 except NotImplementedError:
1199 1199 pass
1200 1200
1201 1201 def _get_repo_pullrequest_sources(
1202 1202 self, repo, commit_id=None, branch=None, bookmark=None):
1203 1203 """
1204 1204 Return a structure with repo's interesting commits, suitable for
1205 1205 the selectors in pullrequest controller
1206 1206
1207 1207 :param commit_id: a commit that must be in the list somehow
1208 1208 and selected by default
1209 1209 :param branch: a branch that must be in the list and selected
1210 1210 by default - even if closed
1211 1211 :param bookmark: a bookmark that must be in the list and selected
1212 1212 """
1213 1213
1214 1214 commit_id = safe_str(commit_id) if commit_id else None
1215 1215 branch = safe_str(branch) if branch else None
1216 1216 bookmark = safe_str(bookmark) if bookmark else None
1217 1217
1218 1218 selected = None
1219 1219
1220 1220 # order matters: first source that has commit_id in it will be selected
1221 1221 sources = []
1222 1222 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1223 1223 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1224 1224
1225 1225 if commit_id:
1226 1226 ref_commit = (h.short_id(commit_id), commit_id)
1227 1227 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1228 1228
1229 1229 sources.append(
1230 1230 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1231 1231 )
1232 1232
1233 1233 groups = []
1234 1234 for group_key, ref_list, group_name, match in sources:
1235 1235 group_refs = []
1236 1236 for ref_name, ref_id in ref_list:
1237 1237 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1238 1238 group_refs.append((ref_key, ref_name))
1239 1239
1240 1240 if not selected:
1241 1241 if set([commit_id, match]) & set([ref_id, ref_name]):
1242 1242 selected = ref_key
1243 1243
1244 1244 if group_refs:
1245 1245 groups.append((group_refs, group_name))
1246 1246
1247 1247 if not selected:
1248 1248 ref = commit_id or branch or bookmark
1249 1249 if ref:
1250 1250 raise CommitDoesNotExistError(
1251 1251 'No commit refs could be found matching: %s' % ref)
1252 1252 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1253 1253 selected = 'branch:%s:%s' % (
1254 1254 repo.DEFAULT_BRANCH_NAME,
1255 1255 repo.branches[repo.DEFAULT_BRANCH_NAME]
1256 1256 )
1257 1257 elif repo.commit_ids:
1258 1258 rev = repo.commit_ids[0]
1259 1259 selected = 'rev:%s:%s' % (rev, rev)
1260 1260 else:
1261 1261 raise EmptyRepositoryError()
1262 1262 return groups, selected
1263 1263
1264 1264 def get_diff(self, pull_request, context=DIFF_CONTEXT):
1265 1265 pull_request = self.__get_pull_request(pull_request)
1266 1266 return self._get_diff_from_pr_or_version(pull_request, context=context)
1267 1267
1268 1268 def _get_diff_from_pr_or_version(self, pr_or_version, context):
1269 1269 source_repo = pr_or_version.source_repo
1270 1270
1271 1271 # we swap org/other ref since we run a simple diff on one repo
1272 1272 target_ref_id = pr_or_version.target_ref_parts.commit_id
1273 1273 source_ref_id = pr_or_version.source_ref_parts.commit_id
1274 1274 target_commit = source_repo.get_commit(
1275 1275 commit_id=safe_str(target_ref_id))
1276 1276 source_commit = source_repo.get_commit(commit_id=safe_str(source_ref_id))
1277 1277 vcs_repo = source_repo.scm_instance()
1278 1278
1279 1279 # TODO: johbo: In the context of an update, we cannot reach
1280 1280 # the old commit anymore with our normal mechanisms. It needs
1281 1281 # some sort of special support in the vcs layer to avoid this
1282 1282 # workaround.
1283 1283 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1284 1284 vcs_repo.alias == 'git'):
1285 1285 source_commit.raw_id = safe_str(source_ref_id)
1286 1286
1287 1287 log.debug('calculating diff between '
1288 1288 'source_ref:%s and target_ref:%s for repo `%s`',
1289 1289 target_ref_id, source_ref_id,
1290 1290 safe_unicode(vcs_repo.path))
1291 1291
1292 1292 vcs_diff = vcs_repo.get_diff(
1293 1293 commit1=target_commit, commit2=source_commit, context=context)
1294 1294 return vcs_diff
1295 1295
1296 1296 def _is_merge_enabled(self, pull_request):
1297 1297 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1298 1298 settings = settings_model.get_general_settings()
1299 1299 return settings.get('rhodecode_pr_merge_enabled', False)
1300 1300
1301 1301 def _use_rebase_for_merging(self, pull_request):
1302 1302 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1303 1303 settings = settings_model.get_general_settings()
1304 1304 return settings.get('rhodecode_hg_use_rebase_for_merging', False)
1305 1305
1306 1306 def _log_action(self, action, user, pull_request):
1307 1307 action_logger(
1308 1308 user,
1309 1309 '{action}:{pr_id}'.format(
1310 1310 action=action, pr_id=pull_request.pull_request_id),
1311 1311 pull_request.target_repo)
1312 1312
1313 1313
1314 1314 ChangeTuple = namedtuple('ChangeTuple',
1315 1315 ['added', 'common', 'removed'])
1316 1316
1317 1317 FileChangeTuple = namedtuple('FileChangeTuple',
1318 1318 ['added', 'modified', 'removed'])
@@ -1,93 +1,93 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 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.tests.events.conftest import EventCatcher
24 24
25 from rhodecode.model.comment import ChangesetCommentsModel
25 from rhodecode.model.comment import CommentsModel
26 26 from rhodecode.model.pull_request import PullRequestModel
27 27 from rhodecode.events import (
28 28 PullRequestCreateEvent,
29 29 PullRequestUpdateEvent,
30 30 PullRequestCommentEvent,
31 31 PullRequestReviewEvent,
32 32 PullRequestMergeEvent,
33 33 PullRequestCloseEvent,
34 34 )
35 35
36 36 # TODO: dan: make the serialization tests complete json comparisons
37 37 @pytest.mark.backends("git", "hg")
38 38 @pytest.mark.parametrize('EventClass', [
39 39 PullRequestCreateEvent,
40 40 PullRequestUpdateEvent,
41 41 PullRequestReviewEvent,
42 42 PullRequestMergeEvent,
43 43 PullRequestCloseEvent,
44 44 ])
45 45 def test_pullrequest_events_serialized(pr_util, EventClass):
46 46 pr = pr_util.create_pull_request()
47 47 event = EventClass(pr)
48 48 data = event.as_dict()
49 49 assert data['name'] == EventClass.name
50 50 assert data['repo']['repo_name'] == pr.target_repo.repo_name
51 51 assert data['pullrequest']['pull_request_id'] == pr.pull_request_id
52 52 assert data['pullrequest']['url']
53 53
54 54 @pytest.mark.backends("git", "hg")
55 55 def test_create_pull_request_events(pr_util):
56 56 with EventCatcher() as event_catcher:
57 57 pr_util.create_pull_request()
58 58
59 59 assert PullRequestCreateEvent in event_catcher.events_types
60 60
61 61 @pytest.mark.backends("git", "hg")
62 62 def test_pullrequest_comment_events_serialized(pr_util):
63 63 pr = pr_util.create_pull_request()
64 comment = ChangesetCommentsModel().get_comments(
64 comment = CommentsModel().get_comments(
65 65 pr.target_repo.repo_id, pull_request=pr)[0]
66 66 event = PullRequestCommentEvent(pr, comment)
67 67 data = event.as_dict()
68 68 assert data['name'] == PullRequestCommentEvent.name
69 69 assert data['repo']['repo_name'] == pr.target_repo.repo_name
70 70 assert data['pullrequest']['pull_request_id'] == pr.pull_request_id
71 71 assert data['pullrequest']['url']
72 72 assert data['comment']['text'] == comment.text
73 73
74 74
75 75 @pytest.mark.backends("git", "hg")
76 76 def test_close_pull_request_events(pr_util, user_admin):
77 77 pr = pr_util.create_pull_request()
78 78
79 79 with EventCatcher() as event_catcher:
80 80 PullRequestModel().close_pull_request(pr, user_admin)
81 81
82 82 assert PullRequestCloseEvent in event_catcher.events_types
83 83
84 84
85 85 @pytest.mark.backends("git", "hg")
86 86 def test_close_pull_request_with_comment_events(pr_util, user_admin):
87 87 pr = pr_util.create_pull_request()
88 88
89 89 with EventCatcher() as event_catcher:
90 90 PullRequestModel().close_pull_request_with_comment(
91 91 pr, user_admin, pr.target_repo)
92 92
93 93 assert PullRequestCloseEvent in event_catcher.events_types
@@ -1,159 +1,159 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 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 import mock
21 21 import pytest
22 22
23 23 from rhodecode.lib.diffs import DiffLineNumber
24 24 from rhodecode.model import comment
25 25
26 26
27 27 @pytest.mark.parametrize('value, expected', [
28 28 ('o1', DiffLineNumber(1, None)),
29 29 ('o798', DiffLineNumber(798, None)),
30 30 ('n1', DiffLineNumber(None, 1)),
31 31 ('n100000', DiffLineNumber(None, 100000)),
32 32 ])
33 33 def test_parse_comment_line_number(value, expected):
34 34 result = comment._parse_comment_line_number(value)
35 35 assert result == expected
36 36
37 37
38 38 @pytest.mark.parametrize('value, should_raise', [
39 39 (None, AttributeError),
40 40 ('2', ValueError),
41 41 ('first_line', ValueError),
42 42 ('olast_line', ValueError),
43 43 ])
44 44 def test_parse_comment_line_number_raises(value, should_raise):
45 45 with pytest.raises(should_raise):
46 46 comment._parse_comment_line_number(value)
47 47
48 48
49 49 @pytest.mark.parametrize('old, new, expected', [
50 50 (None, 1, 'n1'),
51 51 (None, 2, 'n2'),
52 52 (None, 10, 'n10'),
53 53 (1, None, 'o1'),
54 54 (10, None, 'o10'),
55 55 # Ensuring consistent behavior, although there is no line 0 used.
56 56 (None, 0, 'n0'),
57 57 (0, None, 'o0'),
58 58 # Using the empty string to reflect what the comment model does
59 59 (None, None, ''),
60 60 # Give a preference to the new line number if both are given
61 61 (1, 1, 'n1'),
62 62 ])
63 63 def test_diff_to_comment_line_number(old, new, expected):
64 64 diff_line = DiffLineNumber(old=old, new=new)
65 65 comment_line = comment._diff_to_comment_line_number(diff_line)
66 66 assert comment_line == expected
67 67
68 68
69 69 @pytest.mark.parametrize('diff_line, expected', [
70 70 (DiffLineNumber(old=1, new=None), DiffLineNumber(old=2, new=None)),
71 71 (DiffLineNumber(old=11, new=None), DiffLineNumber(old=2, new=None)),
72 72 (DiffLineNumber(old=12, new=None), DiffLineNumber(old=21, new=None)),
73 73 ])
74 74 def test_choose_closest_diff_line_normal(diff_line, expected):
75 comment_model = comment.ChangesetCommentsModel()
75 comment_model = comment.CommentsModel()
76 76 candidates = [
77 77 DiffLineNumber(old=2, new=None),
78 78 DiffLineNumber(old=21, new=None),
79 79 ]
80 80 result = comment_model._choose_closest_diff_line(diff_line, candidates)
81 81 assert result == expected
82 82
83 83
84 84 def test_revision_comments_are_sorted():
85 comment_model = comment.ChangesetCommentsModel()
85 comment_model = comment.CommentsModel()
86 86 query = comment_model._get_inline_comments_query(
87 87 repo_id='fake_repo_name',
88 88 revision='fake_revision',
89 89 pull_request=None)
90 90 assert_inline_comments_order(query)
91 91
92 92
93 93 @pytest.mark.parametrize('use_outdated', [True, False])
94 94 def test_pull_request_comments_are_sorted(use_outdated):
95 comment_model = comment.ChangesetCommentsModel()
95 comment_model = comment.CommentsModel()
96 96 pull_request = mock.Mock()
97 97 # TODO: johbo: Had to do this since we have an inline call to
98 98 # self.__get_pull_request. Should be moved out at some point.
99 99 get_instance_patcher = mock.patch.object(
100 comment.ChangesetCommentsModel, '_get_instance',
100 comment.CommentsModel, '_get_instance',
101 101 return_value=pull_request)
102 102 config_patcher = mock.patch.object(
103 comment.ChangesetCommentsModel, 'use_outdated_comments',
103 comment.CommentsModel, 'use_outdated_comments',
104 104 return_value=use_outdated)
105 105
106 106 with get_instance_patcher, config_patcher as config_mock:
107 107 query = comment_model._get_inline_comments_query(
108 108 repo_id='fake_repo_name',
109 109 revision=None,
110 110 pull_request=pull_request)
111 111 config_mock.assert_called_once_with(pull_request)
112 112 assert_inline_comments_order(query)
113 113
114 114
115 115 def assert_inline_comments_order(query):
116 116 """
117 117 Sorting by ID will make sure that the latest comments are at the bottom.
118 118 """
119 119 order_by = query._order_by
120 120 assert order_by
121 121 assert len(order_by) == 1
122 122 assert str(order_by[0]) == 'changeset_comments.comment_id ASC'
123 123
124 124
125 125 def test_get_renderer():
126 model = comment.ChangesetCommentsModel()
126 model = comment.CommentsModel()
127 127 renderer = model._get_renderer()
128 128 assert renderer == "rst"
129 129
130 130
131 131 class TestUseOutdatedComments(object):
132 132 @pytest.mark.parametrize('use_outdated', [True, False])
133 133 def test_returns_value_from_db(self, use_outdated):
134 134 pull_request = mock.Mock()
135 135
136 136 general_settings = {
137 137 'rhodecode_use_outdated_comments': use_outdated
138 138 }
139 139 with self._patch_settings(general_settings) as settings_mock:
140 result = comment.ChangesetCommentsModel.use_outdated_comments(
140 result = comment.CommentsModel.use_outdated_comments(
141 141 pull_request)
142 142 settings_mock.assert_called_once_with(repo=pull_request.target_repo)
143 143 assert result == use_outdated
144 144
145 145 def test_default_value(self):
146 146 pull_request = mock.Mock()
147 147
148 148 general_settings = {}
149 149 with self._patch_settings(general_settings) as settings_mock:
150 result = comment.ChangesetCommentsModel.use_outdated_comments(
150 result = comment.CommentsModel.use_outdated_comments(
151 151 pull_request)
152 152 settings_mock.assert_called_once_with(repo=pull_request.target_repo)
153 153 assert result is False
154 154
155 155 def _patch_settings(self, value):
156 156 vcs_settings_mock = mock.Mock()
157 157 vcs_settings_mock.get_general_settings.return_value = value
158 158 return mock.patch.object(
159 159 comment, 'VcsSettingsModel', return_value=vcs_settings_mock)
@@ -1,846 +1,846 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 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 mock
22 22 import pytest
23 23 import textwrap
24 24
25 25 import rhodecode
26 26 from rhodecode.lib.utils2 import safe_unicode
27 27 from rhodecode.lib.vcs.backends import get_backend
28 28 from rhodecode.lib.vcs.backends.base import (
29 29 MergeResponse, MergeFailureReason, Reference)
30 30 from rhodecode.lib.vcs.exceptions import RepositoryError
31 31 from rhodecode.lib.vcs.nodes import FileNode
32 from rhodecode.model.comment import ChangesetCommentsModel
32 from rhodecode.model.comment import CommentsModel
33 33 from rhodecode.model.db import PullRequest, Session
34 34 from rhodecode.model.pull_request import PullRequestModel
35 35 from rhodecode.model.user import UserModel
36 36 from rhodecode.tests import TEST_USER_ADMIN_LOGIN
37 37
38 38
39 39 pytestmark = [
40 40 pytest.mark.backends("git", "hg"),
41 41 ]
42 42
43 43
44 44 class TestPullRequestModel:
45 45
46 46 @pytest.fixture
47 47 def pull_request(self, request, backend, pr_util):
48 48 """
49 49 A pull request combined with multiples patches.
50 50 """
51 51 BackendClass = get_backend(backend.alias)
52 52 self.merge_patcher = mock.patch.object(BackendClass, 'merge')
53 53 self.workspace_remove_patcher = mock.patch.object(
54 54 BackendClass, 'cleanup_merge_workspace')
55 55
56 56 self.workspace_remove_mock = self.workspace_remove_patcher.start()
57 57 self.merge_mock = self.merge_patcher.start()
58 58 self.comment_patcher = mock.patch(
59 59 'rhodecode.model.changeset_status.ChangesetStatusModel.set_status')
60 60 self.comment_patcher.start()
61 61 self.notification_patcher = mock.patch(
62 62 'rhodecode.model.notification.NotificationModel.create')
63 63 self.notification_patcher.start()
64 64 self.helper_patcher = mock.patch(
65 65 'rhodecode.lib.helpers.url')
66 66 self.helper_patcher.start()
67 67
68 68 self.hook_patcher = mock.patch.object(PullRequestModel,
69 69 '_trigger_pull_request_hook')
70 70 self.hook_mock = self.hook_patcher.start()
71 71
72 72 self.invalidation_patcher = mock.patch(
73 73 'rhodecode.model.pull_request.ScmModel.mark_for_invalidation')
74 74 self.invalidation_mock = self.invalidation_patcher.start()
75 75
76 76 self.pull_request = pr_util.create_pull_request(
77 77 mergeable=True, name_suffix=u'Δ…Δ‡')
78 78 self.source_commit = self.pull_request.source_ref_parts.commit_id
79 79 self.target_commit = self.pull_request.target_ref_parts.commit_id
80 80 self.workspace_id = 'pr-%s' % self.pull_request.pull_request_id
81 81
82 82 @request.addfinalizer
83 83 def cleanup_pull_request():
84 84 calls = [mock.call(
85 85 self.pull_request, self.pull_request.author, 'create')]
86 86 self.hook_mock.assert_has_calls(calls)
87 87
88 88 self.workspace_remove_patcher.stop()
89 89 self.merge_patcher.stop()
90 90 self.comment_patcher.stop()
91 91 self.notification_patcher.stop()
92 92 self.helper_patcher.stop()
93 93 self.hook_patcher.stop()
94 94 self.invalidation_patcher.stop()
95 95
96 96 return self.pull_request
97 97
98 98 def test_get_all(self, pull_request):
99 99 prs = PullRequestModel().get_all(pull_request.target_repo)
100 100 assert isinstance(prs, list)
101 101 assert len(prs) == 1
102 102
103 103 def test_count_all(self, pull_request):
104 104 pr_count = PullRequestModel().count_all(pull_request.target_repo)
105 105 assert pr_count == 1
106 106
107 107 def test_get_awaiting_review(self, pull_request):
108 108 prs = PullRequestModel().get_awaiting_review(pull_request.target_repo)
109 109 assert isinstance(prs, list)
110 110 assert len(prs) == 1
111 111
112 112 def test_count_awaiting_review(self, pull_request):
113 113 pr_count = PullRequestModel().count_awaiting_review(
114 114 pull_request.target_repo)
115 115 assert pr_count == 1
116 116
117 117 def test_get_awaiting_my_review(self, pull_request):
118 118 PullRequestModel().update_reviewers(
119 119 pull_request, [(pull_request.author, ['author'])])
120 120 prs = PullRequestModel().get_awaiting_my_review(
121 121 pull_request.target_repo, user_id=pull_request.author.user_id)
122 122 assert isinstance(prs, list)
123 123 assert len(prs) == 1
124 124
125 125 def test_count_awaiting_my_review(self, pull_request):
126 126 PullRequestModel().update_reviewers(
127 127 pull_request, [(pull_request.author, ['author'])])
128 128 pr_count = PullRequestModel().count_awaiting_my_review(
129 129 pull_request.target_repo, user_id=pull_request.author.user_id)
130 130 assert pr_count == 1
131 131
132 132 def test_delete_calls_cleanup_merge(self, pull_request):
133 133 PullRequestModel().delete(pull_request)
134 134
135 135 self.workspace_remove_mock.assert_called_once_with(
136 136 self.workspace_id)
137 137
138 138 def test_close_calls_cleanup_and_hook(self, pull_request):
139 139 PullRequestModel().close_pull_request(
140 140 pull_request, pull_request.author)
141 141
142 142 self.workspace_remove_mock.assert_called_once_with(
143 143 self.workspace_id)
144 144 self.hook_mock.assert_called_with(
145 145 self.pull_request, self.pull_request.author, 'close')
146 146
147 147 def test_merge_status(self, pull_request):
148 148 self.merge_mock.return_value = MergeResponse(
149 149 True, False, None, MergeFailureReason.NONE)
150 150
151 151 assert pull_request._last_merge_source_rev is None
152 152 assert pull_request._last_merge_target_rev is None
153 153 assert pull_request._last_merge_status is None
154 154
155 155 status, msg = PullRequestModel().merge_status(pull_request)
156 156 assert status is True
157 157 assert msg.eval() == 'This pull request can be automatically merged.'
158 158 self.merge_mock.assert_called_once_with(
159 159 pull_request.target_ref_parts,
160 160 pull_request.source_repo.scm_instance(),
161 161 pull_request.source_ref_parts, self.workspace_id, dry_run=True,
162 162 use_rebase=False)
163 163
164 164 assert pull_request._last_merge_source_rev == self.source_commit
165 165 assert pull_request._last_merge_target_rev == self.target_commit
166 166 assert pull_request._last_merge_status is MergeFailureReason.NONE
167 167
168 168 self.merge_mock.reset_mock()
169 169 status, msg = PullRequestModel().merge_status(pull_request)
170 170 assert status is True
171 171 assert msg.eval() == 'This pull request can be automatically merged.'
172 172 assert self.merge_mock.called is False
173 173
174 174 def test_merge_status_known_failure(self, pull_request):
175 175 self.merge_mock.return_value = MergeResponse(
176 176 False, False, None, MergeFailureReason.MERGE_FAILED)
177 177
178 178 assert pull_request._last_merge_source_rev is None
179 179 assert pull_request._last_merge_target_rev is None
180 180 assert pull_request._last_merge_status is None
181 181
182 182 status, msg = PullRequestModel().merge_status(pull_request)
183 183 assert status is False
184 184 assert (
185 185 msg.eval() ==
186 186 'This pull request cannot be merged because of conflicts.')
187 187 self.merge_mock.assert_called_once_with(
188 188 pull_request.target_ref_parts,
189 189 pull_request.source_repo.scm_instance(),
190 190 pull_request.source_ref_parts, self.workspace_id, dry_run=True,
191 191 use_rebase=False)
192 192
193 193 assert pull_request._last_merge_source_rev == self.source_commit
194 194 assert pull_request._last_merge_target_rev == self.target_commit
195 195 assert (
196 196 pull_request._last_merge_status is MergeFailureReason.MERGE_FAILED)
197 197
198 198 self.merge_mock.reset_mock()
199 199 status, msg = PullRequestModel().merge_status(pull_request)
200 200 assert status is False
201 201 assert (
202 202 msg.eval() ==
203 203 'This pull request cannot be merged because of conflicts.')
204 204 assert self.merge_mock.called is False
205 205
206 206 def test_merge_status_unknown_failure(self, pull_request):
207 207 self.merge_mock.return_value = MergeResponse(
208 208 False, False, None, MergeFailureReason.UNKNOWN)
209 209
210 210 assert pull_request._last_merge_source_rev is None
211 211 assert pull_request._last_merge_target_rev is None
212 212 assert pull_request._last_merge_status is None
213 213
214 214 status, msg = PullRequestModel().merge_status(pull_request)
215 215 assert status is False
216 216 assert msg.eval() == (
217 217 'This pull request cannot be merged because of an unhandled'
218 218 ' exception.')
219 219 self.merge_mock.assert_called_once_with(
220 220 pull_request.target_ref_parts,
221 221 pull_request.source_repo.scm_instance(),
222 222 pull_request.source_ref_parts, self.workspace_id, dry_run=True,
223 223 use_rebase=False)
224 224
225 225 assert pull_request._last_merge_source_rev is None
226 226 assert pull_request._last_merge_target_rev is None
227 227 assert pull_request._last_merge_status is None
228 228
229 229 self.merge_mock.reset_mock()
230 230 status, msg = PullRequestModel().merge_status(pull_request)
231 231 assert status is False
232 232 assert msg.eval() == (
233 233 'This pull request cannot be merged because of an unhandled'
234 234 ' exception.')
235 235 assert self.merge_mock.called is True
236 236
237 237 def test_merge_status_when_target_is_locked(self, pull_request):
238 238 pull_request.target_repo.locked = [1, u'12345.50', 'lock_web']
239 239 status, msg = PullRequestModel().merge_status(pull_request)
240 240 assert status is False
241 241 assert msg.eval() == (
242 242 'This pull request cannot be merged because the target repository'
243 243 ' is locked.')
244 244
245 245 def test_merge_status_requirements_check_target(self, pull_request):
246 246
247 247 def has_largefiles(self, repo):
248 248 return repo == pull_request.source_repo
249 249
250 250 patcher = mock.patch.object(
251 251 PullRequestModel, '_has_largefiles', has_largefiles)
252 252 with patcher:
253 253 status, msg = PullRequestModel().merge_status(pull_request)
254 254
255 255 assert status is False
256 256 assert msg == 'Target repository large files support is disabled.'
257 257
258 258 def test_merge_status_requirements_check_source(self, pull_request):
259 259
260 260 def has_largefiles(self, repo):
261 261 return repo == pull_request.target_repo
262 262
263 263 patcher = mock.patch.object(
264 264 PullRequestModel, '_has_largefiles', has_largefiles)
265 265 with patcher:
266 266 status, msg = PullRequestModel().merge_status(pull_request)
267 267
268 268 assert status is False
269 269 assert msg == 'Source repository large files support is disabled.'
270 270
271 271 def test_merge(self, pull_request, merge_extras):
272 272 user = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
273 273 merge_ref = Reference(
274 274 'type', 'name', '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
275 275 self.merge_mock.return_value = MergeResponse(
276 276 True, True, merge_ref, MergeFailureReason.NONE)
277 277
278 278 merge_extras['repository'] = pull_request.target_repo.repo_name
279 279 PullRequestModel().merge(
280 280 pull_request, pull_request.author, extras=merge_extras)
281 281
282 282 message = (
283 283 u'Merge pull request #{pr_id} from {source_repo} {source_ref_name}'
284 284 u'\n\n {pr_title}'.format(
285 285 pr_id=pull_request.pull_request_id,
286 286 source_repo=safe_unicode(
287 287 pull_request.source_repo.scm_instance().name),
288 288 source_ref_name=pull_request.source_ref_parts.name,
289 289 pr_title=safe_unicode(pull_request.title)
290 290 )
291 291 )
292 292 self.merge_mock.assert_called_once_with(
293 293 pull_request.target_ref_parts,
294 294 pull_request.source_repo.scm_instance(),
295 295 pull_request.source_ref_parts, self.workspace_id,
296 296 user_name=user.username, user_email=user.email, message=message,
297 297 use_rebase=False
298 298 )
299 299 self.invalidation_mock.assert_called_once_with(
300 300 pull_request.target_repo.repo_name)
301 301
302 302 self.hook_mock.assert_called_with(
303 303 self.pull_request, self.pull_request.author, 'merge')
304 304
305 305 pull_request = PullRequest.get(pull_request.pull_request_id)
306 306 assert (
307 307 pull_request.merge_rev ==
308 308 '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
309 309
310 310 def test_merge_failed(self, pull_request, merge_extras):
311 311 user = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
312 312 merge_ref = Reference(
313 313 'type', 'name', '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
314 314 self.merge_mock.return_value = MergeResponse(
315 315 False, False, merge_ref, MergeFailureReason.MERGE_FAILED)
316 316
317 317 merge_extras['repository'] = pull_request.target_repo.repo_name
318 318 PullRequestModel().merge(
319 319 pull_request, pull_request.author, extras=merge_extras)
320 320
321 321 message = (
322 322 u'Merge pull request #{pr_id} from {source_repo} {source_ref_name}'
323 323 u'\n\n {pr_title}'.format(
324 324 pr_id=pull_request.pull_request_id,
325 325 source_repo=safe_unicode(
326 326 pull_request.source_repo.scm_instance().name),
327 327 source_ref_name=pull_request.source_ref_parts.name,
328 328 pr_title=safe_unicode(pull_request.title)
329 329 )
330 330 )
331 331 self.merge_mock.assert_called_once_with(
332 332 pull_request.target_ref_parts,
333 333 pull_request.source_repo.scm_instance(),
334 334 pull_request.source_ref_parts, self.workspace_id,
335 335 user_name=user.username, user_email=user.email, message=message,
336 336 use_rebase=False
337 337 )
338 338
339 339 pull_request = PullRequest.get(pull_request.pull_request_id)
340 340 assert self.invalidation_mock.called is False
341 341 assert pull_request.merge_rev is None
342 342
343 343 def test_get_commit_ids(self, pull_request):
344 344 # The PR has been not merget yet, so expect an exception
345 345 with pytest.raises(ValueError):
346 346 PullRequestModel()._get_commit_ids(pull_request)
347 347
348 348 # Merge revision is in the revisions list
349 349 pull_request.merge_rev = pull_request.revisions[0]
350 350 commit_ids = PullRequestModel()._get_commit_ids(pull_request)
351 351 assert commit_ids == pull_request.revisions
352 352
353 353 # Merge revision is not in the revisions list
354 354 pull_request.merge_rev = 'f000' * 10
355 355 commit_ids = PullRequestModel()._get_commit_ids(pull_request)
356 356 assert commit_ids == pull_request.revisions + [pull_request.merge_rev]
357 357
358 358 def test_get_diff_from_pr_version(self, pull_request):
359 359 diff = PullRequestModel()._get_diff_from_pr_or_version(
360 360 pull_request, context=6)
361 361 assert 'file_1' in diff.raw
362 362
363 363 def test_generate_title_returns_unicode(self):
364 364 title = PullRequestModel().generate_pullrequest_title(
365 365 source='source-dummy',
366 366 source_ref='source-ref-dummy',
367 367 target='target-dummy',
368 368 )
369 369 assert type(title) == unicode
370 370
371 371
372 372 class TestIntegrationMerge(object):
373 373 @pytest.mark.parametrize('extra_config', (
374 374 {'vcs.hooks.protocol': 'http', 'vcs.hooks.direct_calls': False},
375 375 {'vcs.hooks.protocol': 'Pyro4', 'vcs.hooks.direct_calls': False},
376 376 ))
377 377 def test_merge_triggers_push_hooks(
378 378 self, pr_util, user_admin, capture_rcextensions, merge_extras,
379 379 extra_config):
380 380 pull_request = pr_util.create_pull_request(
381 381 approved=True, mergeable=True)
382 382 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
383 383 merge_extras['repository'] = pull_request.target_repo.repo_name
384 384 Session().commit()
385 385
386 386 with mock.patch.dict(rhodecode.CONFIG, extra_config, clear=False):
387 387 merge_state = PullRequestModel().merge(
388 388 pull_request, user_admin, extras=merge_extras)
389 389
390 390 assert merge_state.executed
391 391 assert 'pre_push' in capture_rcextensions
392 392 assert 'post_push' in capture_rcextensions
393 393
394 394 def test_merge_can_be_rejected_by_pre_push_hook(
395 395 self, pr_util, user_admin, capture_rcextensions, merge_extras):
396 396 pull_request = pr_util.create_pull_request(
397 397 approved=True, mergeable=True)
398 398 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
399 399 merge_extras['repository'] = pull_request.target_repo.repo_name
400 400 Session().commit()
401 401
402 402 with mock.patch('rhodecode.EXTENSIONS.PRE_PUSH_HOOK') as pre_pull:
403 403 pre_pull.side_effect = RepositoryError("Disallow push!")
404 404 merge_status = PullRequestModel().merge(
405 405 pull_request, user_admin, extras=merge_extras)
406 406
407 407 assert not merge_status.executed
408 408 assert 'pre_push' not in capture_rcextensions
409 409 assert 'post_push' not in capture_rcextensions
410 410
411 411 def test_merge_fails_if_target_is_locked(
412 412 self, pr_util, user_regular, merge_extras):
413 413 pull_request = pr_util.create_pull_request(
414 414 approved=True, mergeable=True)
415 415 locked_by = [user_regular.user_id + 1, 12345.50, 'lock_web']
416 416 pull_request.target_repo.locked = locked_by
417 417 # TODO: johbo: Check if this can work based on the database, currently
418 418 # all data is pre-computed, that's why just updating the DB is not
419 419 # enough.
420 420 merge_extras['locked_by'] = locked_by
421 421 merge_extras['repository'] = pull_request.target_repo.repo_name
422 422 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
423 423 Session().commit()
424 424 merge_status = PullRequestModel().merge(
425 425 pull_request, user_regular, extras=merge_extras)
426 426 assert not merge_status.executed
427 427
428 428
429 429 @pytest.mark.parametrize('use_outdated, inlines_count, outdated_count', [
430 430 (False, 1, 0),
431 431 (True, 0, 1),
432 432 ])
433 433 def test_outdated_comments(
434 434 pr_util, use_outdated, inlines_count, outdated_count):
435 435 pull_request = pr_util.create_pull_request()
436 436 pr_util.create_inline_comment(file_path='not_in_updated_diff')
437 437
438 438 with outdated_comments_patcher(use_outdated) as outdated_comment_mock:
439 439 pr_util.add_one_commit()
440 440 assert_inline_comments(
441 441 pull_request, visible=inlines_count, outdated=outdated_count)
442 442 outdated_comment_mock.assert_called_with(pull_request)
443 443
444 444
445 445 @pytest.fixture
446 446 def merge_extras(user_regular):
447 447 """
448 448 Context for the vcs operation when running a merge.
449 449 """
450 450 extras = {
451 451 'ip': '127.0.0.1',
452 452 'username': user_regular.username,
453 453 'action': 'push',
454 454 'repository': 'fake_target_repo_name',
455 455 'scm': 'git',
456 456 'config': 'fake_config_ini_path',
457 457 'make_lock': None,
458 458 'locked_by': [None, None, None],
459 459 'server_url': 'http://test.example.com:5000',
460 460 'hooks': ['push', 'pull'],
461 461 'is_shadow_repo': False,
462 462 }
463 463 return extras
464 464
465 465
466 466 class TestUpdateCommentHandling(object):
467 467
468 468 @pytest.fixture(autouse=True, scope='class')
469 469 def enable_outdated_comments(self, request, pylonsapp):
470 470 config_patch = mock.patch.dict(
471 471 'rhodecode.CONFIG', {'rhodecode_use_outdated_comments': True})
472 472 config_patch.start()
473 473
474 474 @request.addfinalizer
475 475 def cleanup():
476 476 config_patch.stop()
477 477
478 478 def test_comment_stays_unflagged_on_unchanged_diff(self, pr_util):
479 479 commits = [
480 480 {'message': 'a'},
481 481 {'message': 'b', 'added': [FileNode('file_b', 'test_content\n')]},
482 482 {'message': 'c', 'added': [FileNode('file_c', 'test_content\n')]},
483 483 ]
484 484 pull_request = pr_util.create_pull_request(
485 485 commits=commits, target_head='a', source_head='b', revisions=['b'])
486 486 pr_util.create_inline_comment(file_path='file_b')
487 487 pr_util.add_one_commit(head='c')
488 488
489 489 assert_inline_comments(pull_request, visible=1, outdated=0)
490 490
491 491 def test_comment_stays_unflagged_on_change_above(self, pr_util):
492 492 original_content = ''.join(
493 493 ['line {}\n'.format(x) for x in range(1, 11)])
494 494 updated_content = 'new_line_at_top\n' + original_content
495 495 commits = [
496 496 {'message': 'a'},
497 497 {'message': 'b', 'added': [FileNode('file_b', original_content)]},
498 498 {'message': 'c', 'changed': [FileNode('file_b', updated_content)]},
499 499 ]
500 500 pull_request = pr_util.create_pull_request(
501 501 commits=commits, target_head='a', source_head='b', revisions=['b'])
502 502
503 503 with outdated_comments_patcher():
504 504 comment = pr_util.create_inline_comment(
505 505 line_no=u'n8', file_path='file_b')
506 506 pr_util.add_one_commit(head='c')
507 507
508 508 assert_inline_comments(pull_request, visible=1, outdated=0)
509 509 assert comment.line_no == u'n9'
510 510
511 511 def test_comment_stays_unflagged_on_change_below(self, pr_util):
512 512 original_content = ''.join(['line {}\n'.format(x) for x in range(10)])
513 513 updated_content = original_content + 'new_line_at_end\n'
514 514 commits = [
515 515 {'message': 'a'},
516 516 {'message': 'b', 'added': [FileNode('file_b', original_content)]},
517 517 {'message': 'c', 'changed': [FileNode('file_b', updated_content)]},
518 518 ]
519 519 pull_request = pr_util.create_pull_request(
520 520 commits=commits, target_head='a', source_head='b', revisions=['b'])
521 521 pr_util.create_inline_comment(file_path='file_b')
522 522 pr_util.add_one_commit(head='c')
523 523
524 524 assert_inline_comments(pull_request, visible=1, outdated=0)
525 525
526 526 @pytest.mark.parametrize('line_no', ['n4', 'o4', 'n10', 'o9'])
527 527 def test_comment_flagged_on_change_around_context(self, pr_util, line_no):
528 528 base_lines = ['line {}\n'.format(x) for x in range(1, 13)]
529 529 change_lines = list(base_lines)
530 530 change_lines.insert(6, 'line 6a added\n')
531 531
532 532 # Changes on the last line of sight
533 533 update_lines = list(change_lines)
534 534 update_lines[0] = 'line 1 changed\n'
535 535 update_lines[-1] = 'line 12 changed\n'
536 536
537 537 def file_b(lines):
538 538 return FileNode('file_b', ''.join(lines))
539 539
540 540 commits = [
541 541 {'message': 'a', 'added': [file_b(base_lines)]},
542 542 {'message': 'b', 'changed': [file_b(change_lines)]},
543 543 {'message': 'c', 'changed': [file_b(update_lines)]},
544 544 ]
545 545
546 546 pull_request = pr_util.create_pull_request(
547 547 commits=commits, target_head='a', source_head='b', revisions=['b'])
548 548 pr_util.create_inline_comment(line_no=line_no, file_path='file_b')
549 549
550 550 with outdated_comments_patcher():
551 551 pr_util.add_one_commit(head='c')
552 552 assert_inline_comments(pull_request, visible=0, outdated=1)
553 553
554 554 @pytest.mark.parametrize("change, content", [
555 555 ('changed', 'changed\n'),
556 556 ('removed', ''),
557 557 ], ids=['changed', 'removed'])
558 558 def test_comment_flagged_on_change(self, pr_util, change, content):
559 559 commits = [
560 560 {'message': 'a'},
561 561 {'message': 'b', 'added': [FileNode('file_b', 'test_content\n')]},
562 562 {'message': 'c', change: [FileNode('file_b', content)]},
563 563 ]
564 564 pull_request = pr_util.create_pull_request(
565 565 commits=commits, target_head='a', source_head='b', revisions=['b'])
566 566 pr_util.create_inline_comment(file_path='file_b')
567 567
568 568 with outdated_comments_patcher():
569 569 pr_util.add_one_commit(head='c')
570 570 assert_inline_comments(pull_request, visible=0, outdated=1)
571 571
572 572
573 573 class TestUpdateChangedFiles(object):
574 574
575 575 def test_no_changes_on_unchanged_diff(self, pr_util):
576 576 commits = [
577 577 {'message': 'a'},
578 578 {'message': 'b',
579 579 'added': [FileNode('file_b', 'test_content b\n')]},
580 580 {'message': 'c',
581 581 'added': [FileNode('file_c', 'test_content c\n')]},
582 582 ]
583 583 # open a PR from a to b, adding file_b
584 584 pull_request = pr_util.create_pull_request(
585 585 commits=commits, target_head='a', source_head='b', revisions=['b'],
586 586 name_suffix='per-file-review')
587 587
588 588 # modify PR adding new file file_c
589 589 pr_util.add_one_commit(head='c')
590 590
591 591 assert_pr_file_changes(
592 592 pull_request,
593 593 added=['file_c'],
594 594 modified=[],
595 595 removed=[])
596 596
597 597 def test_modify_and_undo_modification_diff(self, pr_util):
598 598 commits = [
599 599 {'message': 'a'},
600 600 {'message': 'b',
601 601 'added': [FileNode('file_b', 'test_content b\n')]},
602 602 {'message': 'c',
603 603 'changed': [FileNode('file_b', 'test_content b modified\n')]},
604 604 {'message': 'd',
605 605 'changed': [FileNode('file_b', 'test_content b\n')]},
606 606 ]
607 607 # open a PR from a to b, adding file_b
608 608 pull_request = pr_util.create_pull_request(
609 609 commits=commits, target_head='a', source_head='b', revisions=['b'],
610 610 name_suffix='per-file-review')
611 611
612 612 # modify PR modifying file file_b
613 613 pr_util.add_one_commit(head='c')
614 614
615 615 assert_pr_file_changes(
616 616 pull_request,
617 617 added=[],
618 618 modified=['file_b'],
619 619 removed=[])
620 620
621 621 # move the head again to d, which rollbacks change,
622 622 # meaning we should indicate no changes
623 623 pr_util.add_one_commit(head='d')
624 624
625 625 assert_pr_file_changes(
626 626 pull_request,
627 627 added=[],
628 628 modified=[],
629 629 removed=[])
630 630
631 631 def test_updated_all_files_in_pr(self, pr_util):
632 632 commits = [
633 633 {'message': 'a'},
634 634 {'message': 'b', 'added': [
635 635 FileNode('file_a', 'test_content a\n'),
636 636 FileNode('file_b', 'test_content b\n'),
637 637 FileNode('file_c', 'test_content c\n')]},
638 638 {'message': 'c', 'changed': [
639 639 FileNode('file_a', 'test_content a changed\n'),
640 640 FileNode('file_b', 'test_content b changed\n'),
641 641 FileNode('file_c', 'test_content c changed\n')]},
642 642 ]
643 643 # open a PR from a to b, changing 3 files
644 644 pull_request = pr_util.create_pull_request(
645 645 commits=commits, target_head='a', source_head='b', revisions=['b'],
646 646 name_suffix='per-file-review')
647 647
648 648 pr_util.add_one_commit(head='c')
649 649
650 650 assert_pr_file_changes(
651 651 pull_request,
652 652 added=[],
653 653 modified=['file_a', 'file_b', 'file_c'],
654 654 removed=[])
655 655
656 656 def test_updated_and_removed_all_files_in_pr(self, pr_util):
657 657 commits = [
658 658 {'message': 'a'},
659 659 {'message': 'b', 'added': [
660 660 FileNode('file_a', 'test_content a\n'),
661 661 FileNode('file_b', 'test_content b\n'),
662 662 FileNode('file_c', 'test_content c\n')]},
663 663 {'message': 'c', 'removed': [
664 664 FileNode('file_a', 'test_content a changed\n'),
665 665 FileNode('file_b', 'test_content b changed\n'),
666 666 FileNode('file_c', 'test_content c changed\n')]},
667 667 ]
668 668 # open a PR from a to b, removing 3 files
669 669 pull_request = pr_util.create_pull_request(
670 670 commits=commits, target_head='a', source_head='b', revisions=['b'],
671 671 name_suffix='per-file-review')
672 672
673 673 pr_util.add_one_commit(head='c')
674 674
675 675 assert_pr_file_changes(
676 676 pull_request,
677 677 added=[],
678 678 modified=[],
679 679 removed=['file_a', 'file_b', 'file_c'])
680 680
681 681
682 682 def test_update_writes_snapshot_into_pull_request_version(pr_util):
683 683 model = PullRequestModel()
684 684 pull_request = pr_util.create_pull_request()
685 685 pr_util.update_source_repository()
686 686
687 687 model.update_commits(pull_request)
688 688
689 689 # Expect that it has a version entry now
690 690 assert len(model.get_versions(pull_request)) == 1
691 691
692 692
693 693 def test_update_skips_new_version_if_unchanged(pr_util):
694 694 pull_request = pr_util.create_pull_request()
695 695 model = PullRequestModel()
696 696 model.update_commits(pull_request)
697 697
698 698 # Expect that it still has no versions
699 699 assert len(model.get_versions(pull_request)) == 0
700 700
701 701
702 702 def test_update_assigns_comments_to_the_new_version(pr_util):
703 703 model = PullRequestModel()
704 704 pull_request = pr_util.create_pull_request()
705 705 comment = pr_util.create_comment()
706 706 pr_util.update_source_repository()
707 707
708 708 model.update_commits(pull_request)
709 709
710 710 # Expect that the comment is linked to the pr version now
711 711 assert comment.pull_request_version == model.get_versions(pull_request)[0]
712 712
713 713
714 714 def test_update_adds_a_comment_to_the_pull_request_about_the_change(pr_util):
715 715 model = PullRequestModel()
716 716 pull_request = pr_util.create_pull_request()
717 717 pr_util.update_source_repository()
718 718 pr_util.update_source_repository()
719 719
720 720 model.update_commits(pull_request)
721 721
722 722 # Expect to find a new comment about the change
723 723 expected_message = textwrap.dedent(
724 724 """\
725 725 Pull request updated. Auto status change to |under_review|
726 726
727 727 .. role:: added
728 728 .. role:: removed
729 729 .. parsed-literal::
730 730
731 731 Changed commits:
732 732 * :added:`1 added`
733 733 * :removed:`0 removed`
734 734
735 735 Changed files:
736 736 * `A file_2 <#a_c--92ed3b5f07b4>`_
737 737
738 738 .. |under_review| replace:: *"Under Review"*"""
739 739 )
740 740 pull_request_comments = sorted(
741 741 pull_request.comments, key=lambda c: c.modified_at)
742 742 update_comment = pull_request_comments[-1]
743 743 assert update_comment.text == expected_message
744 744
745 745
746 746 def test_create_version_from_snapshot_updates_attributes(pr_util):
747 747 pull_request = pr_util.create_pull_request()
748 748
749 749 # Avoiding default values
750 750 pull_request.status = PullRequest.STATUS_CLOSED
751 751 pull_request._last_merge_source_rev = "0" * 40
752 752 pull_request._last_merge_target_rev = "1" * 40
753 753 pull_request._last_merge_status = 1
754 754 pull_request.merge_rev = "2" * 40
755 755
756 756 # Remember automatic values
757 757 created_on = pull_request.created_on
758 758 updated_on = pull_request.updated_on
759 759
760 760 # Create a new version of the pull request
761 761 version = PullRequestModel()._create_version_from_snapshot(pull_request)
762 762
763 763 # Check attributes
764 764 assert version.title == pr_util.create_parameters['title']
765 765 assert version.description == pr_util.create_parameters['description']
766 766 assert version.status == PullRequest.STATUS_CLOSED
767 767
768 768 # versions get updated created_on
769 769 assert version.created_on != created_on
770 770
771 771 assert version.updated_on == updated_on
772 772 assert version.user_id == pull_request.user_id
773 773 assert version.revisions == pr_util.create_parameters['revisions']
774 774 assert version.source_repo == pr_util.source_repository
775 775 assert version.source_ref == pr_util.create_parameters['source_ref']
776 776 assert version.target_repo == pr_util.target_repository
777 777 assert version.target_ref == pr_util.create_parameters['target_ref']
778 778 assert version._last_merge_source_rev == pull_request._last_merge_source_rev
779 779 assert version._last_merge_target_rev == pull_request._last_merge_target_rev
780 780 assert version._last_merge_status == pull_request._last_merge_status
781 781 assert version.merge_rev == pull_request.merge_rev
782 782 assert version.pull_request == pull_request
783 783
784 784
785 785 def test_link_comments_to_version_only_updates_unlinked_comments(pr_util):
786 786 version1 = pr_util.create_version_of_pull_request()
787 787 comment_linked = pr_util.create_comment(linked_to=version1)
788 788 comment_unlinked = pr_util.create_comment()
789 789 version2 = pr_util.create_version_of_pull_request()
790 790
791 791 PullRequestModel()._link_comments_to_version(version2)
792 792
793 793 # Expect that only the new comment is linked to version2
794 794 assert (
795 795 comment_unlinked.pull_request_version_id ==
796 796 version2.pull_request_version_id)
797 797 assert (
798 798 comment_linked.pull_request_version_id ==
799 799 version1.pull_request_version_id)
800 800 assert (
801 801 comment_unlinked.pull_request_version_id !=
802 802 comment_linked.pull_request_version_id)
803 803
804 804
805 805 def test_calculate_commits():
806 806 change = PullRequestModel()._calculate_commit_id_changes(
807 807 set([1, 2, 3]), set([1, 3, 4, 5]))
808 808 assert (set([4, 5]), set([1, 3]), set([2])) == (
809 809 change.added, change.common, change.removed)
810 810
811 811
812 812 def assert_inline_comments(pull_request, visible=None, outdated=None):
813 813 if visible is not None:
814 inline_comments = ChangesetCommentsModel().get_inline_comments(
814 inline_comments = CommentsModel().get_inline_comments(
815 815 pull_request.target_repo.repo_id, pull_request=pull_request)
816 inline_cnt = ChangesetCommentsModel().get_inline_comments_count(
816 inline_cnt = CommentsModel().get_inline_comments_count(
817 817 inline_comments)
818 818 assert inline_cnt == visible
819 819 if outdated is not None:
820 outdated_comments = ChangesetCommentsModel().get_outdated_comments(
820 outdated_comments = CommentsModel().get_outdated_comments(
821 821 pull_request.target_repo.repo_id, pull_request)
822 822 assert len(outdated_comments) == outdated
823 823
824 824
825 825 def assert_pr_file_changes(
826 826 pull_request, added=None, modified=None, removed=None):
827 827 pr_versions = PullRequestModel().get_versions(pull_request)
828 828 # always use first version, ie original PR to calculate changes
829 829 pull_request_version = pr_versions[0]
830 830 old_diff_data, new_diff_data = PullRequestModel()._generate_update_diffs(
831 831 pull_request, pull_request_version)
832 832 file_changes = PullRequestModel()._calculate_file_changes(
833 833 old_diff_data, new_diff_data)
834 834
835 835 assert added == file_changes.added, \
836 836 'expected added:%s vs value:%s' % (added, file_changes.added)
837 837 assert modified == file_changes.modified, \
838 838 'expected modified:%s vs value:%s' % (modified, file_changes.modified)
839 839 assert removed == file_changes.removed, \
840 840 'expected removed:%s vs value:%s' % (removed, file_changes.removed)
841 841
842 842
843 843 def outdated_comments_patcher(use_outdated=True):
844 844 return mock.patch.object(
845 ChangesetCommentsModel, 'use_outdated_comments',
845 CommentsModel, 'use_outdated_comments',
846 846 return_value=use_outdated)
@@ -1,1816 +1,1816 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 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 collections
22 22 import datetime
23 23 import hashlib
24 24 import os
25 25 import re
26 26 import pprint
27 27 import shutil
28 28 import socket
29 29 import subprocess32
30 30 import time
31 31 import uuid
32 32
33 33 import mock
34 34 import pyramid.testing
35 35 import pytest
36 36 import colander
37 37 import requests
38 38
39 39 import rhodecode
40 40 from rhodecode.lib.utils2 import AttributeDict
41 41 from rhodecode.model.changeset_status import ChangesetStatusModel
42 from rhodecode.model.comment import ChangesetCommentsModel
42 from rhodecode.model.comment import CommentsModel
43 43 from rhodecode.model.db import (
44 44 PullRequest, Repository, RhodeCodeSetting, ChangesetStatus, RepoGroup,
45 45 UserGroup, RepoRhodeCodeUi, RepoRhodeCodeSetting, RhodeCodeUi)
46 46 from rhodecode.model.meta import Session
47 47 from rhodecode.model.pull_request import PullRequestModel
48 48 from rhodecode.model.repo import RepoModel
49 49 from rhodecode.model.repo_group import RepoGroupModel
50 50 from rhodecode.model.user import UserModel
51 51 from rhodecode.model.settings import VcsSettingsModel
52 52 from rhodecode.model.user_group import UserGroupModel
53 53 from rhodecode.model.integration import IntegrationModel
54 54 from rhodecode.integrations import integration_type_registry
55 55 from rhodecode.integrations.types.base import IntegrationTypeBase
56 56 from rhodecode.lib.utils import repo2db_mapper
57 57 from rhodecode.lib.vcs import create_vcsserver_proxy
58 58 from rhodecode.lib.vcs.backends import get_backend
59 59 from rhodecode.lib.vcs.nodes import FileNode
60 60 from rhodecode.tests import (
61 61 login_user_session, get_new_dir, utils, TESTS_TMP_PATH,
62 62 TEST_USER_ADMIN_LOGIN, TEST_USER_REGULAR_LOGIN, TEST_USER_REGULAR2_LOGIN,
63 63 TEST_USER_REGULAR_PASS)
64 64 from rhodecode.tests.utils import CustomTestApp
65 65 from rhodecode.tests.fixture import Fixture
66 66
67 67
68 68 def _split_comma(value):
69 69 return value.split(',')
70 70
71 71
72 72 def pytest_addoption(parser):
73 73 parser.addoption(
74 74 '--keep-tmp-path', action='store_true',
75 75 help="Keep the test temporary directories")
76 76 parser.addoption(
77 77 '--backends', action='store', type=_split_comma,
78 78 default=['git', 'hg', 'svn'],
79 79 help="Select which backends to test for backend specific tests.")
80 80 parser.addoption(
81 81 '--dbs', action='store', type=_split_comma,
82 82 default=['sqlite'],
83 83 help="Select which database to test for database specific tests. "
84 84 "Possible options are sqlite,postgres,mysql")
85 85 parser.addoption(
86 86 '--appenlight', '--ae', action='store_true',
87 87 help="Track statistics in appenlight.")
88 88 parser.addoption(
89 89 '--appenlight-api-key', '--ae-key',
90 90 help="API key for Appenlight.")
91 91 parser.addoption(
92 92 '--appenlight-url', '--ae-url',
93 93 default="https://ae.rhodecode.com",
94 94 help="Appenlight service URL, defaults to https://ae.rhodecode.com")
95 95 parser.addoption(
96 96 '--sqlite-connection-string', action='store',
97 97 default='', help="Connection string for the dbs tests with SQLite")
98 98 parser.addoption(
99 99 '--postgres-connection-string', action='store',
100 100 default='', help="Connection string for the dbs tests with Postgres")
101 101 parser.addoption(
102 102 '--mysql-connection-string', action='store',
103 103 default='', help="Connection string for the dbs tests with MySQL")
104 104 parser.addoption(
105 105 '--repeat', type=int, default=100,
106 106 help="Number of repetitions in performance tests.")
107 107
108 108
109 109 def pytest_configure(config):
110 110 # Appy the kombu patch early on, needed for test discovery on Python 2.7.11
111 111 from rhodecode.config import patches
112 112 patches.kombu_1_5_1_python_2_7_11()
113 113
114 114
115 115 def pytest_collection_modifyitems(session, config, items):
116 116 # nottest marked, compare nose, used for transition from nose to pytest
117 117 remaining = [
118 118 i for i in items if getattr(i.obj, '__test__', True)]
119 119 items[:] = remaining
120 120
121 121
122 122 def pytest_generate_tests(metafunc):
123 123 # Support test generation based on --backend parameter
124 124 if 'backend_alias' in metafunc.fixturenames:
125 125 backends = get_backends_from_metafunc(metafunc)
126 126 scope = None
127 127 if not backends:
128 128 pytest.skip("Not enabled for any of selected backends")
129 129 metafunc.parametrize('backend_alias', backends, scope=scope)
130 130 elif hasattr(metafunc.function, 'backends'):
131 131 backends = get_backends_from_metafunc(metafunc)
132 132 if not backends:
133 133 pytest.skip("Not enabled for any of selected backends")
134 134
135 135
136 136 def get_backends_from_metafunc(metafunc):
137 137 requested_backends = set(metafunc.config.getoption('--backends'))
138 138 if hasattr(metafunc.function, 'backends'):
139 139 # Supported backends by this test function, created from
140 140 # pytest.mark.backends
141 141 backends = metafunc.function.backends.args
142 142 elif hasattr(metafunc.cls, 'backend_alias'):
143 143 # Support class attribute "backend_alias", this is mainly
144 144 # for legacy reasons for tests not yet using pytest.mark.backends
145 145 backends = [metafunc.cls.backend_alias]
146 146 else:
147 147 backends = metafunc.config.getoption('--backends')
148 148 return requested_backends.intersection(backends)
149 149
150 150
151 151 @pytest.fixture(scope='session', autouse=True)
152 152 def activate_example_rcextensions(request):
153 153 """
154 154 Patch in an example rcextensions module which verifies passed in kwargs.
155 155 """
156 156 from rhodecode.tests.other import example_rcextensions
157 157
158 158 old_extensions = rhodecode.EXTENSIONS
159 159 rhodecode.EXTENSIONS = example_rcextensions
160 160
161 161 @request.addfinalizer
162 162 def cleanup():
163 163 rhodecode.EXTENSIONS = old_extensions
164 164
165 165
166 166 @pytest.fixture
167 167 def capture_rcextensions():
168 168 """
169 169 Returns the recorded calls to entry points in rcextensions.
170 170 """
171 171 calls = rhodecode.EXTENSIONS.calls
172 172 calls.clear()
173 173 # Note: At this moment, it is still the empty dict, but that will
174 174 # be filled during the test run and since it is a reference this
175 175 # is enough to make it work.
176 176 return calls
177 177
178 178
179 179 @pytest.fixture(scope='session')
180 180 def http_environ_session():
181 181 """
182 182 Allow to use "http_environ" in session scope.
183 183 """
184 184 return http_environ(
185 185 http_host_stub=http_host_stub())
186 186
187 187
188 188 @pytest.fixture
189 189 def http_host_stub():
190 190 """
191 191 Value of HTTP_HOST in the test run.
192 192 """
193 193 return 'test.example.com:80'
194 194
195 195
196 196 @pytest.fixture
197 197 def http_environ(http_host_stub):
198 198 """
199 199 HTTP extra environ keys.
200 200
201 201 User by the test application and as well for setting up the pylons
202 202 environment. In the case of the fixture "app" it should be possible
203 203 to override this for a specific test case.
204 204 """
205 205 return {
206 206 'SERVER_NAME': http_host_stub.split(':')[0],
207 207 'SERVER_PORT': http_host_stub.split(':')[1],
208 208 'HTTP_HOST': http_host_stub,
209 209 }
210 210
211 211
212 212 @pytest.fixture(scope='function')
213 213 def app(request, pylonsapp, http_environ):
214 214
215 215
216 216 app = CustomTestApp(
217 217 pylonsapp,
218 218 extra_environ=http_environ)
219 219 if request.cls:
220 220 request.cls.app = app
221 221 return app
222 222
223 223
224 224 @pytest.fixture(scope='session')
225 225 def app_settings(pylonsapp, pylons_config):
226 226 """
227 227 Settings dictionary used to create the app.
228 228
229 229 Parses the ini file and passes the result through the sanitize and apply
230 230 defaults mechanism in `rhodecode.config.middleware`.
231 231 """
232 232 from paste.deploy.loadwsgi import loadcontext, APP
233 233 from rhodecode.config.middleware import (
234 234 sanitize_settings_and_apply_defaults)
235 235 context = loadcontext(APP, 'config:' + pylons_config)
236 236 settings = sanitize_settings_and_apply_defaults(context.config())
237 237 return settings
238 238
239 239
240 240 @pytest.fixture(scope='session')
241 241 def db(app_settings):
242 242 """
243 243 Initializes the database connection.
244 244
245 245 It uses the same settings which are used to create the ``pylonsapp`` or
246 246 ``app`` fixtures.
247 247 """
248 248 from rhodecode.config.utils import initialize_database
249 249 initialize_database(app_settings)
250 250
251 251
252 252 LoginData = collections.namedtuple('LoginData', ('csrf_token', 'user'))
253 253
254 254
255 255 def _autologin_user(app, *args):
256 256 session = login_user_session(app, *args)
257 257 csrf_token = rhodecode.lib.auth.get_csrf_token(session)
258 258 return LoginData(csrf_token, session['rhodecode_user'])
259 259
260 260
261 261 @pytest.fixture
262 262 def autologin_user(app):
263 263 """
264 264 Utility fixture which makes sure that the admin user is logged in
265 265 """
266 266 return _autologin_user(app)
267 267
268 268
269 269 @pytest.fixture
270 270 def autologin_regular_user(app):
271 271 """
272 272 Utility fixture which makes sure that the regular user is logged in
273 273 """
274 274 return _autologin_user(
275 275 app, TEST_USER_REGULAR_LOGIN, TEST_USER_REGULAR_PASS)
276 276
277 277
278 278 @pytest.fixture(scope='function')
279 279 def csrf_token(request, autologin_user):
280 280 return autologin_user.csrf_token
281 281
282 282
283 283 @pytest.fixture(scope='function')
284 284 def xhr_header(request):
285 285 return {'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}
286 286
287 287
288 288 @pytest.fixture
289 289 def real_crypto_backend(monkeypatch):
290 290 """
291 291 Switch the production crypto backend on for this test.
292 292
293 293 During the test run the crypto backend is replaced with a faster
294 294 implementation based on the MD5 algorithm.
295 295 """
296 296 monkeypatch.setattr(rhodecode, 'is_test', False)
297 297
298 298
299 299 @pytest.fixture(scope='class')
300 300 def index_location(request, pylonsapp):
301 301 index_location = pylonsapp.config['app_conf']['search.location']
302 302 if request.cls:
303 303 request.cls.index_location = index_location
304 304 return index_location
305 305
306 306
307 307 @pytest.fixture(scope='session', autouse=True)
308 308 def tests_tmp_path(request):
309 309 """
310 310 Create temporary directory to be used during the test session.
311 311 """
312 312 if not os.path.exists(TESTS_TMP_PATH):
313 313 os.makedirs(TESTS_TMP_PATH)
314 314
315 315 if not request.config.getoption('--keep-tmp-path'):
316 316 @request.addfinalizer
317 317 def remove_tmp_path():
318 318 shutil.rmtree(TESTS_TMP_PATH)
319 319
320 320 return TESTS_TMP_PATH
321 321
322 322
323 323 @pytest.fixture(scope='session', autouse=True)
324 324 def patch_pyro_request_scope_proxy_factory(request):
325 325 """
326 326 Patch the pyro proxy factory to always use the same dummy request object
327 327 when under test. This will return the same pyro proxy on every call.
328 328 """
329 329 dummy_request = pyramid.testing.DummyRequest()
330 330
331 331 def mocked_call(self, request=None):
332 332 return self.getProxy(request=dummy_request)
333 333
334 334 patcher = mock.patch(
335 335 'rhodecode.lib.vcs.client.RequestScopeProxyFactory.__call__',
336 336 new=mocked_call)
337 337 patcher.start()
338 338
339 339 @request.addfinalizer
340 340 def undo_patching():
341 341 patcher.stop()
342 342
343 343
344 344 @pytest.fixture
345 345 def test_repo_group(request):
346 346 """
347 347 Create a temporary repository group, and destroy it after
348 348 usage automatically
349 349 """
350 350 fixture = Fixture()
351 351 repogroupid = 'test_repo_group_%s' % int(time.time())
352 352 repo_group = fixture.create_repo_group(repogroupid)
353 353
354 354 def _cleanup():
355 355 fixture.destroy_repo_group(repogroupid)
356 356
357 357 request.addfinalizer(_cleanup)
358 358 return repo_group
359 359
360 360
361 361 @pytest.fixture
362 362 def test_user_group(request):
363 363 """
364 364 Create a temporary user group, and destroy it after
365 365 usage automatically
366 366 """
367 367 fixture = Fixture()
368 368 usergroupid = 'test_user_group_%s' % int(time.time())
369 369 user_group = fixture.create_user_group(usergroupid)
370 370
371 371 def _cleanup():
372 372 fixture.destroy_user_group(user_group)
373 373
374 374 request.addfinalizer(_cleanup)
375 375 return user_group
376 376
377 377
378 378 @pytest.fixture(scope='session')
379 379 def test_repo(request):
380 380 container = TestRepoContainer()
381 381 request.addfinalizer(container._cleanup)
382 382 return container
383 383
384 384
385 385 class TestRepoContainer(object):
386 386 """
387 387 Container for test repositories which are used read only.
388 388
389 389 Repositories will be created on demand and re-used during the lifetime
390 390 of this object.
391 391
392 392 Usage to get the svn test repository "minimal"::
393 393
394 394 test_repo = TestContainer()
395 395 repo = test_repo('minimal', 'svn')
396 396
397 397 """
398 398
399 399 dump_extractors = {
400 400 'git': utils.extract_git_repo_from_dump,
401 401 'hg': utils.extract_hg_repo_from_dump,
402 402 'svn': utils.extract_svn_repo_from_dump,
403 403 }
404 404
405 405 def __init__(self):
406 406 self._cleanup_repos = []
407 407 self._fixture = Fixture()
408 408 self._repos = {}
409 409
410 410 def __call__(self, dump_name, backend_alias):
411 411 key = (dump_name, backend_alias)
412 412 if key not in self._repos:
413 413 repo = self._create_repo(dump_name, backend_alias)
414 414 self._repos[key] = repo.repo_id
415 415 return Repository.get(self._repos[key])
416 416
417 417 def _create_repo(self, dump_name, backend_alias):
418 418 repo_name = '%s-%s' % (backend_alias, dump_name)
419 419 backend_class = get_backend(backend_alias)
420 420 dump_extractor = self.dump_extractors[backend_alias]
421 421 repo_path = dump_extractor(dump_name, repo_name)
422 422 vcs_repo = backend_class(repo_path)
423 423 repo2db_mapper({repo_name: vcs_repo})
424 424 repo = RepoModel().get_by_repo_name(repo_name)
425 425 self._cleanup_repos.append(repo_name)
426 426 return repo
427 427
428 428 def _cleanup(self):
429 429 for repo_name in reversed(self._cleanup_repos):
430 430 self._fixture.destroy_repo(repo_name)
431 431
432 432
433 433 @pytest.fixture
434 434 def backend(request, backend_alias, pylonsapp, test_repo):
435 435 """
436 436 Parametrized fixture which represents a single backend implementation.
437 437
438 438 It respects the option `--backends` to focus the test run on specific
439 439 backend implementations.
440 440
441 441 It also supports `pytest.mark.xfail_backends` to mark tests as failing
442 442 for specific backends. This is intended as a utility for incremental
443 443 development of a new backend implementation.
444 444 """
445 445 if backend_alias not in request.config.getoption('--backends'):
446 446 pytest.skip("Backend %s not selected." % (backend_alias, ))
447 447
448 448 utils.check_xfail_backends(request.node, backend_alias)
449 449 utils.check_skip_backends(request.node, backend_alias)
450 450
451 451 repo_name = 'vcs_test_%s' % (backend_alias, )
452 452 backend = Backend(
453 453 alias=backend_alias,
454 454 repo_name=repo_name,
455 455 test_name=request.node.name,
456 456 test_repo_container=test_repo)
457 457 request.addfinalizer(backend.cleanup)
458 458 return backend
459 459
460 460
461 461 @pytest.fixture
462 462 def backend_git(request, pylonsapp, test_repo):
463 463 return backend(request, 'git', pylonsapp, test_repo)
464 464
465 465
466 466 @pytest.fixture
467 467 def backend_hg(request, pylonsapp, test_repo):
468 468 return backend(request, 'hg', pylonsapp, test_repo)
469 469
470 470
471 471 @pytest.fixture
472 472 def backend_svn(request, pylonsapp, test_repo):
473 473 return backend(request, 'svn', pylonsapp, test_repo)
474 474
475 475
476 476 @pytest.fixture
477 477 def backend_random(backend_git):
478 478 """
479 479 Use this to express that your tests need "a backend.
480 480
481 481 A few of our tests need a backend, so that we can run the code. This
482 482 fixture is intended to be used for such cases. It will pick one of the
483 483 backends and run the tests.
484 484
485 485 The fixture `backend` would run the test multiple times for each
486 486 available backend which is a pure waste of time if the test is
487 487 independent of the backend type.
488 488 """
489 489 # TODO: johbo: Change this to pick a random backend
490 490 return backend_git
491 491
492 492
493 493 @pytest.fixture
494 494 def backend_stub(backend_git):
495 495 """
496 496 Use this to express that your tests need a backend stub
497 497
498 498 TODO: mikhail: Implement a real stub logic instead of returning
499 499 a git backend
500 500 """
501 501 return backend_git
502 502
503 503
504 504 @pytest.fixture
505 505 def repo_stub(backend_stub):
506 506 """
507 507 Use this to express that your tests need a repository stub
508 508 """
509 509 return backend_stub.create_repo()
510 510
511 511
512 512 class Backend(object):
513 513 """
514 514 Represents the test configuration for one supported backend
515 515
516 516 Provides easy access to different test repositories based on
517 517 `__getitem__`. Such repositories will only be created once per test
518 518 session.
519 519 """
520 520
521 521 invalid_repo_name = re.compile(r'[^0-9a-zA-Z]+')
522 522 _master_repo = None
523 523 _commit_ids = {}
524 524
525 525 def __init__(self, alias, repo_name, test_name, test_repo_container):
526 526 self.alias = alias
527 527 self.repo_name = repo_name
528 528 self._cleanup_repos = []
529 529 self._test_name = test_name
530 530 self._test_repo_container = test_repo_container
531 531 # TODO: johbo: Used as a delegate interim. Not yet sure if Backend or
532 532 # Fixture will survive in the end.
533 533 self._fixture = Fixture()
534 534
535 535 def __getitem__(self, key):
536 536 return self._test_repo_container(key, self.alias)
537 537
538 538 @property
539 539 def repo(self):
540 540 """
541 541 Returns the "current" repository. This is the vcs_test repo or the
542 542 last repo which has been created with `create_repo`.
543 543 """
544 544 from rhodecode.model.db import Repository
545 545 return Repository.get_by_repo_name(self.repo_name)
546 546
547 547 @property
548 548 def default_branch_name(self):
549 549 VcsRepository = get_backend(self.alias)
550 550 return VcsRepository.DEFAULT_BRANCH_NAME
551 551
552 552 @property
553 553 def default_head_id(self):
554 554 """
555 555 Returns the default head id of the underlying backend.
556 556
557 557 This will be the default branch name in case the backend does have a
558 558 default branch. In the other cases it will point to a valid head
559 559 which can serve as the base to create a new commit on top of it.
560 560 """
561 561 vcsrepo = self.repo.scm_instance()
562 562 head_id = (
563 563 vcsrepo.DEFAULT_BRANCH_NAME or
564 564 vcsrepo.commit_ids[-1])
565 565 return head_id
566 566
567 567 @property
568 568 def commit_ids(self):
569 569 """
570 570 Returns the list of commits for the last created repository
571 571 """
572 572 return self._commit_ids
573 573
574 574 def create_master_repo(self, commits):
575 575 """
576 576 Create a repository and remember it as a template.
577 577
578 578 This allows to easily create derived repositories to construct
579 579 more complex scenarios for diff, compare and pull requests.
580 580
581 581 Returns a commit map which maps from commit message to raw_id.
582 582 """
583 583 self._master_repo = self.create_repo(commits=commits)
584 584 return self._commit_ids
585 585
586 586 def create_repo(
587 587 self, commits=None, number_of_commits=0, heads=None,
588 588 name_suffix=u'', **kwargs):
589 589 """
590 590 Create a repository and record it for later cleanup.
591 591
592 592 :param commits: Optional. A sequence of dict instances.
593 593 Will add a commit per entry to the new repository.
594 594 :param number_of_commits: Optional. If set to a number, this number of
595 595 commits will be added to the new repository.
596 596 :param heads: Optional. Can be set to a sequence of of commit
597 597 names which shall be pulled in from the master repository.
598 598
599 599 """
600 600 self.repo_name = self._next_repo_name() + name_suffix
601 601 repo = self._fixture.create_repo(
602 602 self.repo_name, repo_type=self.alias, **kwargs)
603 603 self._cleanup_repos.append(repo.repo_name)
604 604
605 605 commits = commits or [
606 606 {'message': 'Commit %s of %s' % (x, self.repo_name)}
607 607 for x in xrange(number_of_commits)]
608 608 self._add_commits_to_repo(repo.scm_instance(), commits)
609 609 if heads:
610 610 self.pull_heads(repo, heads)
611 611
612 612 return repo
613 613
614 614 def pull_heads(self, repo, heads):
615 615 """
616 616 Make sure that repo contains all commits mentioned in `heads`
617 617 """
618 618 vcsmaster = self._master_repo.scm_instance()
619 619 vcsrepo = repo.scm_instance()
620 620 vcsrepo.config.clear_section('hooks')
621 621 commit_ids = [self._commit_ids[h] for h in heads]
622 622 vcsrepo.pull(vcsmaster.path, commit_ids=commit_ids)
623 623
624 624 def create_fork(self):
625 625 repo_to_fork = self.repo_name
626 626 self.repo_name = self._next_repo_name()
627 627 repo = self._fixture.create_fork(repo_to_fork, self.repo_name)
628 628 self._cleanup_repos.append(self.repo_name)
629 629 return repo
630 630
631 631 def new_repo_name(self, suffix=u''):
632 632 self.repo_name = self._next_repo_name() + suffix
633 633 self._cleanup_repos.append(self.repo_name)
634 634 return self.repo_name
635 635
636 636 def _next_repo_name(self):
637 637 return u"%s_%s" % (
638 638 self.invalid_repo_name.sub(u'_', self._test_name),
639 639 len(self._cleanup_repos))
640 640
641 641 def ensure_file(self, filename, content='Test content\n'):
642 642 assert self._cleanup_repos, "Avoid writing into vcs_test repos"
643 643 commits = [
644 644 {'added': [
645 645 FileNode(filename, content=content),
646 646 ]},
647 647 ]
648 648 self._add_commits_to_repo(self.repo.scm_instance(), commits)
649 649
650 650 def enable_downloads(self):
651 651 repo = self.repo
652 652 repo.enable_downloads = True
653 653 Session().add(repo)
654 654 Session().commit()
655 655
656 656 def cleanup(self):
657 657 for repo_name in reversed(self._cleanup_repos):
658 658 self._fixture.destroy_repo(repo_name)
659 659
660 660 def _add_commits_to_repo(self, repo, commits):
661 661 commit_ids = _add_commits_to_repo(repo, commits)
662 662 if not commit_ids:
663 663 return
664 664 self._commit_ids = commit_ids
665 665
666 666 # Creating refs for Git to allow fetching them from remote repository
667 667 if self.alias == 'git':
668 668 refs = {}
669 669 for message in self._commit_ids:
670 670 # TODO: mikhail: do more special chars replacements
671 671 ref_name = 'refs/test-refs/{}'.format(
672 672 message.replace(' ', ''))
673 673 refs[ref_name] = self._commit_ids[message]
674 674 self._create_refs(repo, refs)
675 675
676 676 def _create_refs(self, repo, refs):
677 677 for ref_name in refs:
678 678 repo.set_refs(ref_name, refs[ref_name])
679 679
680 680
681 681 @pytest.fixture
682 682 def vcsbackend(request, backend_alias, tests_tmp_path, pylonsapp, test_repo):
683 683 """
684 684 Parametrized fixture which represents a single vcs backend implementation.
685 685
686 686 See the fixture `backend` for more details. This one implements the same
687 687 concept, but on vcs level. So it does not provide model instances etc.
688 688
689 689 Parameters are generated dynamically, see :func:`pytest_generate_tests`
690 690 for how this works.
691 691 """
692 692 if backend_alias not in request.config.getoption('--backends'):
693 693 pytest.skip("Backend %s not selected." % (backend_alias, ))
694 694
695 695 utils.check_xfail_backends(request.node, backend_alias)
696 696 utils.check_skip_backends(request.node, backend_alias)
697 697
698 698 repo_name = 'vcs_test_%s' % (backend_alias, )
699 699 repo_path = os.path.join(tests_tmp_path, repo_name)
700 700 backend = VcsBackend(
701 701 alias=backend_alias,
702 702 repo_path=repo_path,
703 703 test_name=request.node.name,
704 704 test_repo_container=test_repo)
705 705 request.addfinalizer(backend.cleanup)
706 706 return backend
707 707
708 708
709 709 @pytest.fixture
710 710 def vcsbackend_git(request, tests_tmp_path, pylonsapp, test_repo):
711 711 return vcsbackend(request, 'git', tests_tmp_path, pylonsapp, test_repo)
712 712
713 713
714 714 @pytest.fixture
715 715 def vcsbackend_hg(request, tests_tmp_path, pylonsapp, test_repo):
716 716 return vcsbackend(request, 'hg', tests_tmp_path, pylonsapp, test_repo)
717 717
718 718
719 719 @pytest.fixture
720 720 def vcsbackend_svn(request, tests_tmp_path, pylonsapp, test_repo):
721 721 return vcsbackend(request, 'svn', tests_tmp_path, pylonsapp, test_repo)
722 722
723 723
724 724 @pytest.fixture
725 725 def vcsbackend_random(vcsbackend_git):
726 726 """
727 727 Use this to express that your tests need "a vcsbackend".
728 728
729 729 The fixture `vcsbackend` would run the test multiple times for each
730 730 available vcs backend which is a pure waste of time if the test is
731 731 independent of the vcs backend type.
732 732 """
733 733 # TODO: johbo: Change this to pick a random backend
734 734 return vcsbackend_git
735 735
736 736
737 737 @pytest.fixture
738 738 def vcsbackend_stub(vcsbackend_git):
739 739 """
740 740 Use this to express that your test just needs a stub of a vcsbackend.
741 741
742 742 Plan is to eventually implement an in-memory stub to speed tests up.
743 743 """
744 744 return vcsbackend_git
745 745
746 746
747 747 class VcsBackend(object):
748 748 """
749 749 Represents the test configuration for one supported vcs backend.
750 750 """
751 751
752 752 invalid_repo_name = re.compile(r'[^0-9a-zA-Z]+')
753 753
754 754 def __init__(self, alias, repo_path, test_name, test_repo_container):
755 755 self.alias = alias
756 756 self._repo_path = repo_path
757 757 self._cleanup_repos = []
758 758 self._test_name = test_name
759 759 self._test_repo_container = test_repo_container
760 760
761 761 def __getitem__(self, key):
762 762 return self._test_repo_container(key, self.alias).scm_instance()
763 763
764 764 @property
765 765 def repo(self):
766 766 """
767 767 Returns the "current" repository. This is the vcs_test repo of the last
768 768 repo which has been created.
769 769 """
770 770 Repository = get_backend(self.alias)
771 771 return Repository(self._repo_path)
772 772
773 773 @property
774 774 def backend(self):
775 775 """
776 776 Returns the backend implementation class.
777 777 """
778 778 return get_backend(self.alias)
779 779
780 780 def create_repo(self, commits=None, number_of_commits=0, _clone_repo=None):
781 781 repo_name = self._next_repo_name()
782 782 self._repo_path = get_new_dir(repo_name)
783 783 repo_class = get_backend(self.alias)
784 784 src_url = None
785 785 if _clone_repo:
786 786 src_url = _clone_repo.path
787 787 repo = repo_class(self._repo_path, create=True, src_url=src_url)
788 788 self._cleanup_repos.append(repo)
789 789
790 790 commits = commits or [
791 791 {'message': 'Commit %s of %s' % (x, repo_name)}
792 792 for x in xrange(number_of_commits)]
793 793 _add_commits_to_repo(repo, commits)
794 794 return repo
795 795
796 796 def clone_repo(self, repo):
797 797 return self.create_repo(_clone_repo=repo)
798 798
799 799 def cleanup(self):
800 800 for repo in self._cleanup_repos:
801 801 shutil.rmtree(repo.path)
802 802
803 803 def new_repo_path(self):
804 804 repo_name = self._next_repo_name()
805 805 self._repo_path = get_new_dir(repo_name)
806 806 return self._repo_path
807 807
808 808 def _next_repo_name(self):
809 809 return "%s_%s" % (
810 810 self.invalid_repo_name.sub('_', self._test_name),
811 811 len(self._cleanup_repos))
812 812
813 813 def add_file(self, repo, filename, content='Test content\n'):
814 814 imc = repo.in_memory_commit
815 815 imc.add(FileNode(filename, content=content))
816 816 imc.commit(
817 817 message=u'Automatic commit from vcsbackend fixture',
818 818 author=u'Automatic')
819 819
820 820 def ensure_file(self, filename, content='Test content\n'):
821 821 assert self._cleanup_repos, "Avoid writing into vcs_test repos"
822 822 self.add_file(self.repo, filename, content)
823 823
824 824
825 825 def _add_commits_to_repo(vcs_repo, commits):
826 826 commit_ids = {}
827 827 if not commits:
828 828 return commit_ids
829 829
830 830 imc = vcs_repo.in_memory_commit
831 831 commit = None
832 832
833 833 for idx, commit in enumerate(commits):
834 834 message = unicode(commit.get('message', 'Commit %s' % idx))
835 835
836 836 for node in commit.get('added', []):
837 837 imc.add(FileNode(node.path, content=node.content))
838 838 for node in commit.get('changed', []):
839 839 imc.change(FileNode(node.path, content=node.content))
840 840 for node in commit.get('removed', []):
841 841 imc.remove(FileNode(node.path))
842 842
843 843 parents = [
844 844 vcs_repo.get_commit(commit_id=commit_ids[p])
845 845 for p in commit.get('parents', [])]
846 846
847 847 operations = ('added', 'changed', 'removed')
848 848 if not any((commit.get(o) for o in operations)):
849 849 imc.add(FileNode('file_%s' % idx, content=message))
850 850
851 851 commit = imc.commit(
852 852 message=message,
853 853 author=unicode(commit.get('author', 'Automatic')),
854 854 date=commit.get('date'),
855 855 branch=commit.get('branch'),
856 856 parents=parents)
857 857
858 858 commit_ids[commit.message] = commit.raw_id
859 859
860 860 return commit_ids
861 861
862 862
863 863 @pytest.fixture
864 864 def reposerver(request):
865 865 """
866 866 Allows to serve a backend repository
867 867 """
868 868
869 869 repo_server = RepoServer()
870 870 request.addfinalizer(repo_server.cleanup)
871 871 return repo_server
872 872
873 873
874 874 class RepoServer(object):
875 875 """
876 876 Utility to serve a local repository for the duration of a test case.
877 877
878 878 Supports only Subversion so far.
879 879 """
880 880
881 881 url = None
882 882
883 883 def __init__(self):
884 884 self._cleanup_servers = []
885 885
886 886 def serve(self, vcsrepo):
887 887 if vcsrepo.alias != 'svn':
888 888 raise TypeError("Backend %s not supported" % vcsrepo.alias)
889 889
890 890 proc = subprocess32.Popen(
891 891 ['svnserve', '-d', '--foreground', '--listen-host', 'localhost',
892 892 '--root', vcsrepo.path])
893 893 self._cleanup_servers.append(proc)
894 894 self.url = 'svn://localhost'
895 895
896 896 def cleanup(self):
897 897 for proc in self._cleanup_servers:
898 898 proc.terminate()
899 899
900 900
901 901 @pytest.fixture
902 902 def pr_util(backend, request):
903 903 """
904 904 Utility for tests of models and for functional tests around pull requests.
905 905
906 906 It gives an instance of :class:`PRTestUtility` which provides various
907 907 utility methods around one pull request.
908 908
909 909 This fixture uses `backend` and inherits its parameterization.
910 910 """
911 911
912 912 util = PRTestUtility(backend)
913 913
914 914 @request.addfinalizer
915 915 def cleanup():
916 916 util.cleanup()
917 917
918 918 return util
919 919
920 920
921 921 class PRTestUtility(object):
922 922
923 923 pull_request = None
924 924 pull_request_id = None
925 925 mergeable_patcher = None
926 926 mergeable_mock = None
927 927 notification_patcher = None
928 928
929 929 def __init__(self, backend):
930 930 self.backend = backend
931 931
932 932 def create_pull_request(
933 933 self, commits=None, target_head=None, source_head=None,
934 934 revisions=None, approved=False, author=None, mergeable=False,
935 935 enable_notifications=True, name_suffix=u'', reviewers=None,
936 936 title=u"Test", description=u"Description"):
937 937 self.set_mergeable(mergeable)
938 938 if not enable_notifications:
939 939 # mock notification side effect
940 940 self.notification_patcher = mock.patch(
941 941 'rhodecode.model.notification.NotificationModel.create')
942 942 self.notification_patcher.start()
943 943
944 944 if not self.pull_request:
945 945 if not commits:
946 946 commits = [
947 947 {'message': 'c1'},
948 948 {'message': 'c2'},
949 949 {'message': 'c3'},
950 950 ]
951 951 target_head = 'c1'
952 952 source_head = 'c2'
953 953 revisions = ['c2']
954 954
955 955 self.commit_ids = self.backend.create_master_repo(commits)
956 956 self.target_repository = self.backend.create_repo(
957 957 heads=[target_head], name_suffix=name_suffix)
958 958 self.source_repository = self.backend.create_repo(
959 959 heads=[source_head], name_suffix=name_suffix)
960 960 self.author = author or UserModel().get_by_username(
961 961 TEST_USER_ADMIN_LOGIN)
962 962
963 963 model = PullRequestModel()
964 964 self.create_parameters = {
965 965 'created_by': self.author,
966 966 'source_repo': self.source_repository.repo_name,
967 967 'source_ref': self._default_branch_reference(source_head),
968 968 'target_repo': self.target_repository.repo_name,
969 969 'target_ref': self._default_branch_reference(target_head),
970 970 'revisions': [self.commit_ids[r] for r in revisions],
971 971 'reviewers': reviewers or self._get_reviewers(),
972 972 'title': title,
973 973 'description': description,
974 974 }
975 975 self.pull_request = model.create(**self.create_parameters)
976 976 assert model.get_versions(self.pull_request) == []
977 977
978 978 self.pull_request_id = self.pull_request.pull_request_id
979 979
980 980 if approved:
981 981 self.approve()
982 982
983 983 Session().add(self.pull_request)
984 984 Session().commit()
985 985
986 986 return self.pull_request
987 987
988 988 def approve(self):
989 989 self.create_status_votes(
990 990 ChangesetStatus.STATUS_APPROVED,
991 991 *self.pull_request.reviewers)
992 992
993 993 def close(self):
994 994 PullRequestModel().close_pull_request(self.pull_request, self.author)
995 995
996 996 def _default_branch_reference(self, commit_message):
997 997 reference = '%s:%s:%s' % (
998 998 'branch',
999 999 self.backend.default_branch_name,
1000 1000 self.commit_ids[commit_message])
1001 1001 return reference
1002 1002
1003 1003 def _get_reviewers(self):
1004 1004 model = UserModel()
1005 1005 return [
1006 1006 model.get_by_username(TEST_USER_REGULAR_LOGIN),
1007 1007 model.get_by_username(TEST_USER_REGULAR2_LOGIN),
1008 1008 ]
1009 1009
1010 1010 def update_source_repository(self, head=None):
1011 1011 heads = [head or 'c3']
1012 1012 self.backend.pull_heads(self.source_repository, heads=heads)
1013 1013
1014 1014 def add_one_commit(self, head=None):
1015 1015 self.update_source_repository(head=head)
1016 1016 old_commit_ids = set(self.pull_request.revisions)
1017 1017 PullRequestModel().update_commits(self.pull_request)
1018 1018 commit_ids = set(self.pull_request.revisions)
1019 1019 new_commit_ids = commit_ids - old_commit_ids
1020 1020 assert len(new_commit_ids) == 1
1021 1021 return new_commit_ids.pop()
1022 1022
1023 1023 def remove_one_commit(self):
1024 1024 assert len(self.pull_request.revisions) == 2
1025 1025 source_vcs = self.source_repository.scm_instance()
1026 1026 removed_commit_id = source_vcs.commit_ids[-1]
1027 1027
1028 1028 # TODO: johbo: Git and Mercurial have an inconsistent vcs api here,
1029 1029 # remove the if once that's sorted out.
1030 1030 if self.backend.alias == "git":
1031 1031 kwargs = {'branch_name': self.backend.default_branch_name}
1032 1032 else:
1033 1033 kwargs = {}
1034 1034 source_vcs.strip(removed_commit_id, **kwargs)
1035 1035
1036 1036 PullRequestModel().update_commits(self.pull_request)
1037 1037 assert len(self.pull_request.revisions) == 1
1038 1038 return removed_commit_id
1039 1039
1040 1040 def create_comment(self, linked_to=None):
1041 comment = ChangesetCommentsModel().create(
1041 comment = CommentsModel().create(
1042 1042 text=u"Test comment",
1043 1043 repo=self.target_repository.repo_name,
1044 1044 user=self.author,
1045 1045 pull_request=self.pull_request)
1046 1046 assert comment.pull_request_version_id is None
1047 1047
1048 1048 if linked_to:
1049 1049 PullRequestModel()._link_comments_to_version(linked_to)
1050 1050
1051 1051 return comment
1052 1052
1053 1053 def create_inline_comment(
1054 1054 self, linked_to=None, line_no=u'n1', file_path='file_1'):
1055 comment = ChangesetCommentsModel().create(
1055 comment = CommentsModel().create(
1056 1056 text=u"Test comment",
1057 1057 repo=self.target_repository.repo_name,
1058 1058 user=self.author,
1059 1059 line_no=line_no,
1060 1060 f_path=file_path,
1061 1061 pull_request=self.pull_request)
1062 1062 assert comment.pull_request_version_id is None
1063 1063
1064 1064 if linked_to:
1065 1065 PullRequestModel()._link_comments_to_version(linked_to)
1066 1066
1067 1067 return comment
1068 1068
1069 1069 def create_version_of_pull_request(self):
1070 1070 pull_request = self.create_pull_request()
1071 1071 version = PullRequestModel()._create_version_from_snapshot(
1072 1072 pull_request)
1073 1073 return version
1074 1074
1075 1075 def create_status_votes(self, status, *reviewers):
1076 1076 for reviewer in reviewers:
1077 1077 ChangesetStatusModel().set_status(
1078 1078 repo=self.pull_request.target_repo,
1079 1079 status=status,
1080 1080 user=reviewer.user_id,
1081 1081 pull_request=self.pull_request)
1082 1082
1083 1083 def set_mergeable(self, value):
1084 1084 if not self.mergeable_patcher:
1085 1085 self.mergeable_patcher = mock.patch.object(
1086 1086 VcsSettingsModel, 'get_general_settings')
1087 1087 self.mergeable_mock = self.mergeable_patcher.start()
1088 1088 self.mergeable_mock.return_value = {
1089 1089 'rhodecode_pr_merge_enabled': value}
1090 1090
1091 1091 def cleanup(self):
1092 1092 # In case the source repository is already cleaned up, the pull
1093 1093 # request will already be deleted.
1094 1094 pull_request = PullRequest().get(self.pull_request_id)
1095 1095 if pull_request:
1096 1096 PullRequestModel().delete(pull_request)
1097 1097 Session().commit()
1098 1098
1099 1099 if self.notification_patcher:
1100 1100 self.notification_patcher.stop()
1101 1101
1102 1102 if self.mergeable_patcher:
1103 1103 self.mergeable_patcher.stop()
1104 1104
1105 1105
1106 1106 @pytest.fixture
1107 1107 def user_admin(pylonsapp):
1108 1108 """
1109 1109 Provides the default admin test user as an instance of `db.User`.
1110 1110 """
1111 1111 user = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
1112 1112 return user
1113 1113
1114 1114
1115 1115 @pytest.fixture
1116 1116 def user_regular(pylonsapp):
1117 1117 """
1118 1118 Provides the default regular test user as an instance of `db.User`.
1119 1119 """
1120 1120 user = UserModel().get_by_username(TEST_USER_REGULAR_LOGIN)
1121 1121 return user
1122 1122
1123 1123
1124 1124 @pytest.fixture
1125 1125 def user_util(request, pylonsapp):
1126 1126 """
1127 1127 Provides a wired instance of `UserUtility` with integrated cleanup.
1128 1128 """
1129 1129 utility = UserUtility(test_name=request.node.name)
1130 1130 request.addfinalizer(utility.cleanup)
1131 1131 return utility
1132 1132
1133 1133
1134 1134 # TODO: johbo: Split this up into utilities per domain or something similar
1135 1135 class UserUtility(object):
1136 1136
1137 1137 def __init__(self, test_name="test"):
1138 1138 self._test_name = self._sanitize_name(test_name)
1139 1139 self.fixture = Fixture()
1140 1140 self.repo_group_ids = []
1141 1141 self.repos_ids = []
1142 1142 self.user_ids = []
1143 1143 self.user_group_ids = []
1144 1144 self.user_repo_permission_ids = []
1145 1145 self.user_group_repo_permission_ids = []
1146 1146 self.user_repo_group_permission_ids = []
1147 1147 self.user_group_repo_group_permission_ids = []
1148 1148 self.user_user_group_permission_ids = []
1149 1149 self.user_group_user_group_permission_ids = []
1150 1150 self.user_permissions = []
1151 1151
1152 1152 def _sanitize_name(self, name):
1153 1153 for char in ['[', ']']:
1154 1154 name = name.replace(char, '_')
1155 1155 return name
1156 1156
1157 1157 def create_repo_group(
1158 1158 self, owner=TEST_USER_ADMIN_LOGIN, auto_cleanup=True):
1159 1159 group_name = "{prefix}_repogroup_{count}".format(
1160 1160 prefix=self._test_name,
1161 1161 count=len(self.repo_group_ids))
1162 1162 repo_group = self.fixture.create_repo_group(
1163 1163 group_name, cur_user=owner)
1164 1164 if auto_cleanup:
1165 1165 self.repo_group_ids.append(repo_group.group_id)
1166 1166 return repo_group
1167 1167
1168 1168 def create_repo(self, owner=TEST_USER_ADMIN_LOGIN, parent=None, auto_cleanup=True):
1169 1169 repo_name = "{prefix}_repository_{count}".format(
1170 1170 prefix=self._test_name,
1171 1171 count=len(self.repos_ids))
1172 1172
1173 1173 repository = self.fixture.create_repo(
1174 1174 repo_name, cur_user=owner, repo_group=parent)
1175 1175 if auto_cleanup:
1176 1176 self.repos_ids.append(repository.repo_id)
1177 1177 return repository
1178 1178
1179 1179 def create_user(self, auto_cleanup=True, **kwargs):
1180 1180 user_name = "{prefix}_user_{count}".format(
1181 1181 prefix=self._test_name,
1182 1182 count=len(self.user_ids))
1183 1183 user = self.fixture.create_user(user_name, **kwargs)
1184 1184 if auto_cleanup:
1185 1185 self.user_ids.append(user.user_id)
1186 1186 return user
1187 1187
1188 1188 def create_user_with_group(self):
1189 1189 user = self.create_user()
1190 1190 user_group = self.create_user_group(members=[user])
1191 1191 return user, user_group
1192 1192
1193 1193 def create_user_group(self, members=None, auto_cleanup=True, **kwargs):
1194 1194 group_name = "{prefix}_usergroup_{count}".format(
1195 1195 prefix=self._test_name,
1196 1196 count=len(self.user_group_ids))
1197 1197 user_group = self.fixture.create_user_group(group_name, **kwargs)
1198 1198 if auto_cleanup:
1199 1199 self.user_group_ids.append(user_group.users_group_id)
1200 1200 if members:
1201 1201 for user in members:
1202 1202 UserGroupModel().add_user_to_group(user_group, user)
1203 1203 return user_group
1204 1204
1205 1205 def grant_user_permission(self, user_name, permission_name):
1206 1206 self._inherit_default_user_permissions(user_name, False)
1207 1207 self.user_permissions.append((user_name, permission_name))
1208 1208
1209 1209 def grant_user_permission_to_repo_group(
1210 1210 self, repo_group, user, permission_name):
1211 1211 permission = RepoGroupModel().grant_user_permission(
1212 1212 repo_group, user, permission_name)
1213 1213 self.user_repo_group_permission_ids.append(
1214 1214 (repo_group.group_id, user.user_id))
1215 1215 return permission
1216 1216
1217 1217 def grant_user_group_permission_to_repo_group(
1218 1218 self, repo_group, user_group, permission_name):
1219 1219 permission = RepoGroupModel().grant_user_group_permission(
1220 1220 repo_group, user_group, permission_name)
1221 1221 self.user_group_repo_group_permission_ids.append(
1222 1222 (repo_group.group_id, user_group.users_group_id))
1223 1223 return permission
1224 1224
1225 1225 def grant_user_permission_to_repo(
1226 1226 self, repo, user, permission_name):
1227 1227 permission = RepoModel().grant_user_permission(
1228 1228 repo, user, permission_name)
1229 1229 self.user_repo_permission_ids.append(
1230 1230 (repo.repo_id, user.user_id))
1231 1231 return permission
1232 1232
1233 1233 def grant_user_group_permission_to_repo(
1234 1234 self, repo, user_group, permission_name):
1235 1235 permission = RepoModel().grant_user_group_permission(
1236 1236 repo, user_group, permission_name)
1237 1237 self.user_group_repo_permission_ids.append(
1238 1238 (repo.repo_id, user_group.users_group_id))
1239 1239 return permission
1240 1240
1241 1241 def grant_user_permission_to_user_group(
1242 1242 self, target_user_group, user, permission_name):
1243 1243 permission = UserGroupModel().grant_user_permission(
1244 1244 target_user_group, user, permission_name)
1245 1245 self.user_user_group_permission_ids.append(
1246 1246 (target_user_group.users_group_id, user.user_id))
1247 1247 return permission
1248 1248
1249 1249 def grant_user_group_permission_to_user_group(
1250 1250 self, target_user_group, user_group, permission_name):
1251 1251 permission = UserGroupModel().grant_user_group_permission(
1252 1252 target_user_group, user_group, permission_name)
1253 1253 self.user_group_user_group_permission_ids.append(
1254 1254 (target_user_group.users_group_id, user_group.users_group_id))
1255 1255 return permission
1256 1256
1257 1257 def revoke_user_permission(self, user_name, permission_name):
1258 1258 self._inherit_default_user_permissions(user_name, True)
1259 1259 UserModel().revoke_perm(user_name, permission_name)
1260 1260
1261 1261 def _inherit_default_user_permissions(self, user_name, value):
1262 1262 user = UserModel().get_by_username(user_name)
1263 1263 user.inherit_default_permissions = value
1264 1264 Session().add(user)
1265 1265 Session().commit()
1266 1266
1267 1267 def cleanup(self):
1268 1268 self._cleanup_permissions()
1269 1269 self._cleanup_repos()
1270 1270 self._cleanup_repo_groups()
1271 1271 self._cleanup_user_groups()
1272 1272 self._cleanup_users()
1273 1273
1274 1274 def _cleanup_permissions(self):
1275 1275 if self.user_permissions:
1276 1276 for user_name, permission_name in self.user_permissions:
1277 1277 self.revoke_user_permission(user_name, permission_name)
1278 1278
1279 1279 for permission in self.user_repo_permission_ids:
1280 1280 RepoModel().revoke_user_permission(*permission)
1281 1281
1282 1282 for permission in self.user_group_repo_permission_ids:
1283 1283 RepoModel().revoke_user_group_permission(*permission)
1284 1284
1285 1285 for permission in self.user_repo_group_permission_ids:
1286 1286 RepoGroupModel().revoke_user_permission(*permission)
1287 1287
1288 1288 for permission in self.user_group_repo_group_permission_ids:
1289 1289 RepoGroupModel().revoke_user_group_permission(*permission)
1290 1290
1291 1291 for permission in self.user_user_group_permission_ids:
1292 1292 UserGroupModel().revoke_user_permission(*permission)
1293 1293
1294 1294 for permission in self.user_group_user_group_permission_ids:
1295 1295 UserGroupModel().revoke_user_group_permission(*permission)
1296 1296
1297 1297 def _cleanup_repo_groups(self):
1298 1298 def _repo_group_compare(first_group_id, second_group_id):
1299 1299 """
1300 1300 Gives higher priority to the groups with the most complex paths
1301 1301 """
1302 1302 first_group = RepoGroup.get(first_group_id)
1303 1303 second_group = RepoGroup.get(second_group_id)
1304 1304 first_group_parts = (
1305 1305 len(first_group.group_name.split('/')) if first_group else 0)
1306 1306 second_group_parts = (
1307 1307 len(second_group.group_name.split('/')) if second_group else 0)
1308 1308 return cmp(second_group_parts, first_group_parts)
1309 1309
1310 1310 sorted_repo_group_ids = sorted(
1311 1311 self.repo_group_ids, cmp=_repo_group_compare)
1312 1312 for repo_group_id in sorted_repo_group_ids:
1313 1313 self.fixture.destroy_repo_group(repo_group_id)
1314 1314
1315 1315 def _cleanup_repos(self):
1316 1316 sorted_repos_ids = sorted(self.repos_ids)
1317 1317 for repo_id in sorted_repos_ids:
1318 1318 self.fixture.destroy_repo(repo_id)
1319 1319
1320 1320 def _cleanup_user_groups(self):
1321 1321 def _user_group_compare(first_group_id, second_group_id):
1322 1322 """
1323 1323 Gives higher priority to the groups with the most complex paths
1324 1324 """
1325 1325 first_group = UserGroup.get(first_group_id)
1326 1326 second_group = UserGroup.get(second_group_id)
1327 1327 first_group_parts = (
1328 1328 len(first_group.users_group_name.split('/'))
1329 1329 if first_group else 0)
1330 1330 second_group_parts = (
1331 1331 len(second_group.users_group_name.split('/'))
1332 1332 if second_group else 0)
1333 1333 return cmp(second_group_parts, first_group_parts)
1334 1334
1335 1335 sorted_user_group_ids = sorted(
1336 1336 self.user_group_ids, cmp=_user_group_compare)
1337 1337 for user_group_id in sorted_user_group_ids:
1338 1338 self.fixture.destroy_user_group(user_group_id)
1339 1339
1340 1340 def _cleanup_users(self):
1341 1341 for user_id in self.user_ids:
1342 1342 self.fixture.destroy_user(user_id)
1343 1343
1344 1344
1345 1345 # TODO: Think about moving this into a pytest-pyro package and make it a
1346 1346 # pytest plugin
1347 1347 @pytest.hookimpl(tryfirst=True, hookwrapper=True)
1348 1348 def pytest_runtest_makereport(item, call):
1349 1349 """
1350 1350 Adding the remote traceback if the exception has this information.
1351 1351
1352 1352 Pyro4 attaches this information as the attribute `_vcs_server_traceback`
1353 1353 to the exception instance.
1354 1354 """
1355 1355 outcome = yield
1356 1356 report = outcome.get_result()
1357 1357 if call.excinfo:
1358 1358 _add_vcsserver_remote_traceback(report, call.excinfo.value)
1359 1359
1360 1360
1361 1361 def _add_vcsserver_remote_traceback(report, exc):
1362 1362 vcsserver_traceback = getattr(exc, '_vcs_server_traceback', None)
1363 1363
1364 1364 if vcsserver_traceback:
1365 1365 section = 'VCSServer remote traceback ' + report.when
1366 1366 report.sections.append((section, vcsserver_traceback))
1367 1367
1368 1368
1369 1369 @pytest.fixture(scope='session')
1370 1370 def testrun():
1371 1371 return {
1372 1372 'uuid': uuid.uuid4(),
1373 1373 'start': datetime.datetime.utcnow().isoformat(),
1374 1374 'timestamp': int(time.time()),
1375 1375 }
1376 1376
1377 1377
1378 1378 @pytest.fixture(autouse=True)
1379 1379 def collect_appenlight_stats(request, testrun):
1380 1380 """
1381 1381 This fixture reports memory consumtion of single tests.
1382 1382
1383 1383 It gathers data based on `psutil` and sends them to Appenlight. The option
1384 1384 ``--ae`` has te be used to enable this fixture and the API key for your
1385 1385 application has to be provided in ``--ae-key``.
1386 1386 """
1387 1387 try:
1388 1388 # cygwin cannot have yet psutil support.
1389 1389 import psutil
1390 1390 except ImportError:
1391 1391 return
1392 1392
1393 1393 if not request.config.getoption('--appenlight'):
1394 1394 return
1395 1395 else:
1396 1396 # Only request the pylonsapp fixture if appenlight tracking is
1397 1397 # enabled. This will speed up a test run of unit tests by 2 to 3
1398 1398 # seconds if appenlight is not enabled.
1399 1399 pylonsapp = request.getfuncargvalue("pylonsapp")
1400 1400 url = '{}/api/logs'.format(request.config.getoption('--appenlight-url'))
1401 1401 client = AppenlightClient(
1402 1402 url=url,
1403 1403 api_key=request.config.getoption('--appenlight-api-key'),
1404 1404 namespace=request.node.nodeid,
1405 1405 request=str(testrun['uuid']),
1406 1406 testrun=testrun)
1407 1407
1408 1408 client.collect({
1409 1409 'message': "Starting",
1410 1410 })
1411 1411
1412 1412 server_and_port = pylonsapp.config['vcs.server']
1413 1413 server = create_vcsserver_proxy(server_and_port)
1414 1414 with server:
1415 1415 vcs_pid = server.get_pid()
1416 1416 server.run_gc()
1417 1417 vcs_process = psutil.Process(vcs_pid)
1418 1418 mem = vcs_process.memory_info()
1419 1419 client.tag_before('vcsserver.rss', mem.rss)
1420 1420 client.tag_before('vcsserver.vms', mem.vms)
1421 1421
1422 1422 test_process = psutil.Process()
1423 1423 mem = test_process.memory_info()
1424 1424 client.tag_before('test.rss', mem.rss)
1425 1425 client.tag_before('test.vms', mem.vms)
1426 1426
1427 1427 client.tag_before('time', time.time())
1428 1428
1429 1429 @request.addfinalizer
1430 1430 def send_stats():
1431 1431 client.tag_after('time', time.time())
1432 1432 with server:
1433 1433 gc_stats = server.run_gc()
1434 1434 for tag, value in gc_stats.items():
1435 1435 client.tag_after(tag, value)
1436 1436 mem = vcs_process.memory_info()
1437 1437 client.tag_after('vcsserver.rss', mem.rss)
1438 1438 client.tag_after('vcsserver.vms', mem.vms)
1439 1439
1440 1440 mem = test_process.memory_info()
1441 1441 client.tag_after('test.rss', mem.rss)
1442 1442 client.tag_after('test.vms', mem.vms)
1443 1443
1444 1444 client.collect({
1445 1445 'message': "Finished",
1446 1446 })
1447 1447 client.send_stats()
1448 1448
1449 1449 return client
1450 1450
1451 1451
1452 1452 class AppenlightClient():
1453 1453
1454 1454 url_template = '{url}?protocol_version=0.5'
1455 1455
1456 1456 def __init__(
1457 1457 self, url, api_key, add_server=True, add_timestamp=True,
1458 1458 namespace=None, request=None, testrun=None):
1459 1459 self.url = self.url_template.format(url=url)
1460 1460 self.api_key = api_key
1461 1461 self.add_server = add_server
1462 1462 self.add_timestamp = add_timestamp
1463 1463 self.namespace = namespace
1464 1464 self.request = request
1465 1465 self.server = socket.getfqdn(socket.gethostname())
1466 1466 self.tags_before = {}
1467 1467 self.tags_after = {}
1468 1468 self.stats = []
1469 1469 self.testrun = testrun or {}
1470 1470
1471 1471 def tag_before(self, tag, value):
1472 1472 self.tags_before[tag] = value
1473 1473
1474 1474 def tag_after(self, tag, value):
1475 1475 self.tags_after[tag] = value
1476 1476
1477 1477 def collect(self, data):
1478 1478 if self.add_server:
1479 1479 data.setdefault('server', self.server)
1480 1480 if self.add_timestamp:
1481 1481 data.setdefault('date', datetime.datetime.utcnow().isoformat())
1482 1482 if self.namespace:
1483 1483 data.setdefault('namespace', self.namespace)
1484 1484 if self.request:
1485 1485 data.setdefault('request', self.request)
1486 1486 self.stats.append(data)
1487 1487
1488 1488 def send_stats(self):
1489 1489 tags = [
1490 1490 ('testrun', self.request),
1491 1491 ('testrun.start', self.testrun['start']),
1492 1492 ('testrun.timestamp', self.testrun['timestamp']),
1493 1493 ('test', self.namespace),
1494 1494 ]
1495 1495 for key, value in self.tags_before.items():
1496 1496 tags.append((key + '.before', value))
1497 1497 try:
1498 1498 delta = self.tags_after[key] - value
1499 1499 tags.append((key + '.delta', delta))
1500 1500 except Exception:
1501 1501 pass
1502 1502 for key, value in self.tags_after.items():
1503 1503 tags.append((key + '.after', value))
1504 1504 self.collect({
1505 1505 'message': "Collected tags",
1506 1506 'tags': tags,
1507 1507 })
1508 1508
1509 1509 response = requests.post(
1510 1510 self.url,
1511 1511 headers={
1512 1512 'X-appenlight-api-key': self.api_key},
1513 1513 json=self.stats,
1514 1514 )
1515 1515
1516 1516 if not response.status_code == 200:
1517 1517 pprint.pprint(self.stats)
1518 1518 print response.headers
1519 1519 print response.text
1520 1520 raise Exception('Sending to appenlight failed')
1521 1521
1522 1522
1523 1523 @pytest.fixture
1524 1524 def gist_util(request, pylonsapp):
1525 1525 """
1526 1526 Provides a wired instance of `GistUtility` with integrated cleanup.
1527 1527 """
1528 1528 utility = GistUtility()
1529 1529 request.addfinalizer(utility.cleanup)
1530 1530 return utility
1531 1531
1532 1532
1533 1533 class GistUtility(object):
1534 1534 def __init__(self):
1535 1535 self.fixture = Fixture()
1536 1536 self.gist_ids = []
1537 1537
1538 1538 def create_gist(self, **kwargs):
1539 1539 gist = self.fixture.create_gist(**kwargs)
1540 1540 self.gist_ids.append(gist.gist_id)
1541 1541 return gist
1542 1542
1543 1543 def cleanup(self):
1544 1544 for id_ in self.gist_ids:
1545 1545 self.fixture.destroy_gists(str(id_))
1546 1546
1547 1547
1548 1548 @pytest.fixture
1549 1549 def enabled_backends(request):
1550 1550 backends = request.config.option.backends
1551 1551 return backends[:]
1552 1552
1553 1553
1554 1554 @pytest.fixture
1555 1555 def settings_util(request):
1556 1556 """
1557 1557 Provides a wired instance of `SettingsUtility` with integrated cleanup.
1558 1558 """
1559 1559 utility = SettingsUtility()
1560 1560 request.addfinalizer(utility.cleanup)
1561 1561 return utility
1562 1562
1563 1563
1564 1564 class SettingsUtility(object):
1565 1565 def __init__(self):
1566 1566 self.rhodecode_ui_ids = []
1567 1567 self.rhodecode_setting_ids = []
1568 1568 self.repo_rhodecode_ui_ids = []
1569 1569 self.repo_rhodecode_setting_ids = []
1570 1570
1571 1571 def create_repo_rhodecode_ui(
1572 1572 self, repo, section, value, key=None, active=True, cleanup=True):
1573 1573 key = key or hashlib.sha1(
1574 1574 '{}{}{}'.format(section, value, repo.repo_id)).hexdigest()
1575 1575
1576 1576 setting = RepoRhodeCodeUi()
1577 1577 setting.repository_id = repo.repo_id
1578 1578 setting.ui_section = section
1579 1579 setting.ui_value = value
1580 1580 setting.ui_key = key
1581 1581 setting.ui_active = active
1582 1582 Session().add(setting)
1583 1583 Session().commit()
1584 1584
1585 1585 if cleanup:
1586 1586 self.repo_rhodecode_ui_ids.append(setting.ui_id)
1587 1587 return setting
1588 1588
1589 1589 def create_rhodecode_ui(
1590 1590 self, section, value, key=None, active=True, cleanup=True):
1591 1591 key = key or hashlib.sha1('{}{}'.format(section, value)).hexdigest()
1592 1592
1593 1593 setting = RhodeCodeUi()
1594 1594 setting.ui_section = section
1595 1595 setting.ui_value = value
1596 1596 setting.ui_key = key
1597 1597 setting.ui_active = active
1598 1598 Session().add(setting)
1599 1599 Session().commit()
1600 1600
1601 1601 if cleanup:
1602 1602 self.rhodecode_ui_ids.append(setting.ui_id)
1603 1603 return setting
1604 1604
1605 1605 def create_repo_rhodecode_setting(
1606 1606 self, repo, name, value, type_, cleanup=True):
1607 1607 setting = RepoRhodeCodeSetting(
1608 1608 repo.repo_id, key=name, val=value, type=type_)
1609 1609 Session().add(setting)
1610 1610 Session().commit()
1611 1611
1612 1612 if cleanup:
1613 1613 self.repo_rhodecode_setting_ids.append(setting.app_settings_id)
1614 1614 return setting
1615 1615
1616 1616 def create_rhodecode_setting(self, name, value, type_, cleanup=True):
1617 1617 setting = RhodeCodeSetting(key=name, val=value, type=type_)
1618 1618 Session().add(setting)
1619 1619 Session().commit()
1620 1620
1621 1621 if cleanup:
1622 1622 self.rhodecode_setting_ids.append(setting.app_settings_id)
1623 1623
1624 1624 return setting
1625 1625
1626 1626 def cleanup(self):
1627 1627 for id_ in self.rhodecode_ui_ids:
1628 1628 setting = RhodeCodeUi.get(id_)
1629 1629 Session().delete(setting)
1630 1630
1631 1631 for id_ in self.rhodecode_setting_ids:
1632 1632 setting = RhodeCodeSetting.get(id_)
1633 1633 Session().delete(setting)
1634 1634
1635 1635 for id_ in self.repo_rhodecode_ui_ids:
1636 1636 setting = RepoRhodeCodeUi.get(id_)
1637 1637 Session().delete(setting)
1638 1638
1639 1639 for id_ in self.repo_rhodecode_setting_ids:
1640 1640 setting = RepoRhodeCodeSetting.get(id_)
1641 1641 Session().delete(setting)
1642 1642
1643 1643 Session().commit()
1644 1644
1645 1645
1646 1646 @pytest.fixture
1647 1647 def no_notifications(request):
1648 1648 notification_patcher = mock.patch(
1649 1649 'rhodecode.model.notification.NotificationModel.create')
1650 1650 notification_patcher.start()
1651 1651 request.addfinalizer(notification_patcher.stop)
1652 1652
1653 1653
1654 1654 @pytest.fixture
1655 1655 def silence_action_logger(request):
1656 1656 notification_patcher = mock.patch(
1657 1657 'rhodecode.lib.utils.action_logger')
1658 1658 notification_patcher.start()
1659 1659 request.addfinalizer(notification_patcher.stop)
1660 1660
1661 1661
1662 1662 @pytest.fixture(scope='session')
1663 1663 def repeat(request):
1664 1664 """
1665 1665 The number of repetitions is based on this fixture.
1666 1666
1667 1667 Slower calls may divide it by 10 or 100. It is chosen in a way so that the
1668 1668 tests are not too slow in our default test suite.
1669 1669 """
1670 1670 return request.config.getoption('--repeat')
1671 1671
1672 1672
1673 1673 @pytest.fixture
1674 1674 def rhodecode_fixtures():
1675 1675 return Fixture()
1676 1676
1677 1677
1678 1678 @pytest.fixture
1679 1679 def request_stub():
1680 1680 """
1681 1681 Stub request object.
1682 1682 """
1683 1683 request = pyramid.testing.DummyRequest()
1684 1684 request.scheme = 'https'
1685 1685 return request
1686 1686
1687 1687
1688 1688 @pytest.fixture
1689 1689 def config_stub(request, request_stub):
1690 1690 """
1691 1691 Set up pyramid.testing and return the Configurator.
1692 1692 """
1693 1693 config = pyramid.testing.setUp(request=request_stub)
1694 1694
1695 1695 @request.addfinalizer
1696 1696 def cleanup():
1697 1697 pyramid.testing.tearDown()
1698 1698
1699 1699 return config
1700 1700
1701 1701
1702 1702 @pytest.fixture
1703 1703 def StubIntegrationType():
1704 1704 class _StubIntegrationType(IntegrationTypeBase):
1705 1705 """ Test integration type class """
1706 1706
1707 1707 key = 'test'
1708 1708 display_name = 'Test integration type'
1709 1709 description = 'A test integration type for testing'
1710 1710 icon = 'test_icon_html_image'
1711 1711
1712 1712 def __init__(self, settings):
1713 1713 super(_StubIntegrationType, self).__init__(settings)
1714 1714 self.sent_events = [] # for testing
1715 1715
1716 1716 def send_event(self, event):
1717 1717 self.sent_events.append(event)
1718 1718
1719 1719 def settings_schema(self):
1720 1720 class SettingsSchema(colander.Schema):
1721 1721 test_string_field = colander.SchemaNode(
1722 1722 colander.String(),
1723 1723 missing=colander.required,
1724 1724 title='test string field',
1725 1725 )
1726 1726 test_int_field = colander.SchemaNode(
1727 1727 colander.Int(),
1728 1728 title='some integer setting',
1729 1729 )
1730 1730 return SettingsSchema()
1731 1731
1732 1732
1733 1733 integration_type_registry.register_integration_type(_StubIntegrationType)
1734 1734 return _StubIntegrationType
1735 1735
1736 1736 @pytest.fixture
1737 1737 def stub_integration_settings():
1738 1738 return {
1739 1739 'test_string_field': 'some data',
1740 1740 'test_int_field': 100,
1741 1741 }
1742 1742
1743 1743
1744 1744 @pytest.fixture
1745 1745 def repo_integration_stub(request, repo_stub, StubIntegrationType,
1746 1746 stub_integration_settings):
1747 1747 integration = IntegrationModel().create(
1748 1748 StubIntegrationType, settings=stub_integration_settings, enabled=True,
1749 1749 name='test repo integration',
1750 1750 repo=repo_stub, repo_group=None, child_repos_only=None)
1751 1751
1752 1752 @request.addfinalizer
1753 1753 def cleanup():
1754 1754 IntegrationModel().delete(integration)
1755 1755
1756 1756 return integration
1757 1757
1758 1758
1759 1759 @pytest.fixture
1760 1760 def repogroup_integration_stub(request, test_repo_group, StubIntegrationType,
1761 1761 stub_integration_settings):
1762 1762 integration = IntegrationModel().create(
1763 1763 StubIntegrationType, settings=stub_integration_settings, enabled=True,
1764 1764 name='test repogroup integration',
1765 1765 repo=None, repo_group=test_repo_group, child_repos_only=True)
1766 1766
1767 1767 @request.addfinalizer
1768 1768 def cleanup():
1769 1769 IntegrationModel().delete(integration)
1770 1770
1771 1771 return integration
1772 1772
1773 1773
1774 1774 @pytest.fixture
1775 1775 def repogroup_recursive_integration_stub(request, test_repo_group,
1776 1776 StubIntegrationType, stub_integration_settings):
1777 1777 integration = IntegrationModel().create(
1778 1778 StubIntegrationType, settings=stub_integration_settings, enabled=True,
1779 1779 name='test recursive repogroup integration',
1780 1780 repo=None, repo_group=test_repo_group, child_repos_only=False)
1781 1781
1782 1782 @request.addfinalizer
1783 1783 def cleanup():
1784 1784 IntegrationModel().delete(integration)
1785 1785
1786 1786 return integration
1787 1787
1788 1788
1789 1789 @pytest.fixture
1790 1790 def global_integration_stub(request, StubIntegrationType,
1791 1791 stub_integration_settings):
1792 1792 integration = IntegrationModel().create(
1793 1793 StubIntegrationType, settings=stub_integration_settings, enabled=True,
1794 1794 name='test global integration',
1795 1795 repo=None, repo_group=None, child_repos_only=None)
1796 1796
1797 1797 @request.addfinalizer
1798 1798 def cleanup():
1799 1799 IntegrationModel().delete(integration)
1800 1800
1801 1801 return integration
1802 1802
1803 1803
1804 1804 @pytest.fixture
1805 1805 def root_repos_integration_stub(request, StubIntegrationType,
1806 1806 stub_integration_settings):
1807 1807 integration = IntegrationModel().create(
1808 1808 StubIntegrationType, settings=stub_integration_settings, enabled=True,
1809 1809 name='test global integration',
1810 1810 repo=None, repo_group=None, child_repos_only=True)
1811 1811
1812 1812 @request.addfinalizer
1813 1813 def cleanup():
1814 1814 IntegrationModel().delete(integration)
1815 1815
1816 1816 return integration
General Comments 0
You need to be logged in to leave comments. Login now