##// END OF EJS Templates
pull-request-api: updated logic of closing a PR via API call....
marcink -
r1792:a62f3dac default
parent child Browse files
Show More
@@ -1,112 +1,113 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.model.db import UserLog
24 24 from rhodecode.model.pull_request import PullRequestModel
25 25 from rhodecode.tests import TEST_USER_ADMIN_LOGIN
26 26 from rhodecode.api.tests.utils import (
27 27 build_data, api_call, assert_error, assert_ok)
28 28
29 29
30 30 @pytest.mark.usefixtures("testuser_api", "app")
31 31 class TestClosePullRequest(object):
32 32
33 33 @pytest.mark.backends("git", "hg")
34 34 def test_api_close_pull_request(self, pr_util):
35 35 pull_request = pr_util.create_pull_request()
36 36 pull_request_id = pull_request.pull_request_id
37 37 author = pull_request.user_id
38 38 repo = pull_request.target_repo.repo_id
39 39 id_, params = build_data(
40 40 self.apikey, 'close_pull_request',
41 41 repoid=pull_request.target_repo.repo_name,
42 42 pullrequestid=pull_request.pull_request_id)
43 43 response = api_call(self.app, params)
44 44 expected = {
45 45 'pull_request_id': pull_request_id,
46 'close_status': 'Rejected',
46 47 'closed': True,
47 48 }
48 49 assert_ok(id_, expected, response.body)
49 50 action = 'user_closed_pull_request:%d' % pull_request_id
50 51 journal = UserLog.query()\
51 52 .filter(UserLog.user_id == author)\
52 53 .filter(UserLog.repository_id == repo)\
53 54 .filter(UserLog.action == action)\
54 55 .all()
55 56 assert len(journal) == 1
56 57
57 58 @pytest.mark.backends("git", "hg")
58 59 def test_api_close_pull_request_already_closed_error(self, pr_util):
59 60 pull_request = pr_util.create_pull_request()
60 61 pull_request_id = pull_request.pull_request_id
61 62 pull_request_repo = pull_request.target_repo.repo_name
62 63 PullRequestModel().close_pull_request(
63 64 pull_request, pull_request.author)
64 65 id_, params = build_data(
65 66 self.apikey, 'close_pull_request',
66 67 repoid=pull_request_repo, pullrequestid=pull_request_id)
67 68 response = api_call(self.app, params)
68 69
69 70 expected = 'pull request `%s` is already closed' % pull_request_id
70 71 assert_error(id_, expected, given=response.body)
71 72
72 73 @pytest.mark.backends("git", "hg")
73 74 def test_api_close_pull_request_repo_error(self):
74 75 id_, params = build_data(
75 76 self.apikey, 'close_pull_request',
76 77 repoid=666, pullrequestid=1)
77 78 response = api_call(self.app, params)
78 79
79 80 expected = 'repository `666` does not exist'
80 81 assert_error(id_, expected, given=response.body)
81 82
82 83 @pytest.mark.backends("git", "hg")
83 84 def test_api_close_pull_request_non_admin_with_userid_error(self,
84 85 pr_util):
85 86 pull_request = pr_util.create_pull_request()
86 87 id_, params = build_data(
87 88 self.apikey_regular, 'close_pull_request',
88 89 repoid=pull_request.target_repo.repo_name,
89 90 pullrequestid=pull_request.pull_request_id,
90 91 userid=TEST_USER_ADMIN_LOGIN)
91 92 response = api_call(self.app, params)
92 93
93 94 expected = 'userid is not the same as your user'
94 95 assert_error(id_, expected, given=response.body)
95 96
96 97 @pytest.mark.backends("git", "hg")
97 98 def test_api_close_pull_request_no_perms_to_close(
98 99 self, user_util, pr_util):
99 100 user = user_util.create_user()
100 101 pull_request = pr_util.create_pull_request()
101 102
102 103 id_, params = build_data(
103 104 user.api_key, 'close_pull_request',
104 105 repoid=pull_request.target_repo.repo_name,
105 106 pullrequestid=pull_request.pull_request_id,)
106 107 response = api_call(self.app, params)
107 108
108 109 expected = ('pull request `%s` close failed, '
109 110 'no permission to close.') % pull_request.pull_request_id
110 111
111 112 response_json = response.json['error']
112 113 assert response_json == expected
@@ -1,734 +1,748 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 from rhodecode import events
24 25 from rhodecode.api import jsonrpc_method, JSONRPCError, JSONRPCValidationError
25 26 from rhodecode.api.utils import (
26 27 has_superadmin_permission, Optional, OAttr, get_repo_or_error,
27 28 get_pull_request_or_error, get_commit_or_error, get_user_or_error,
28 29 validate_repo_permissions, resolve_ref_or_error)
29 30 from rhodecode.lib.auth import (HasRepoPermissionAnyApi)
30 31 from rhodecode.lib.base import vcs_operation_context
31 32 from rhodecode.lib.utils2 import str2bool
32 33 from rhodecode.model.changeset_status import ChangesetStatusModel
33 34 from rhodecode.model.comment import CommentsModel
34 35 from rhodecode.model.db import Session, ChangesetStatus, ChangesetComment
35 36 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
36 37 from rhodecode.model.settings import SettingsModel
37 38 from rhodecode.model.validation_schema import Invalid
38 from rhodecode.model.validation_schema.schemas.reviewer_schema import \
39 ReviewerListSchema
39 from rhodecode.model.validation_schema.schemas.reviewer_schema import(
40 ReviewerListSchema)
40 41
41 42 log = logging.getLogger(__name__)
42 43
43 44
44 45 @jsonrpc_method()
45 46 def get_pull_request(request, apiuser, repoid, pullrequestid):
46 47 """
47 48 Get a pull request based on the given ID.
48 49
49 50 :param apiuser: This is filled automatically from the |authtoken|.
50 51 :type apiuser: AuthUser
51 52 :param repoid: Repository name or repository ID from where the pull
52 53 request was opened.
53 54 :type repoid: str or int
54 55 :param pullrequestid: ID of the requested pull request.
55 56 :type pullrequestid: int
56 57
57 58 Example output:
58 59
59 60 .. code-block:: bash
60 61
61 62 "id": <id_given_in_input>,
62 63 "result":
63 64 {
64 65 "pull_request_id": "<pull_request_id>",
65 66 "url": "<url>",
66 67 "title": "<title>",
67 68 "description": "<description>",
68 69 "status" : "<status>",
69 70 "created_on": "<date_time_created>",
70 71 "updated_on": "<date_time_updated>",
71 72 "commit_ids": [
72 73 ...
73 74 "<commit_id>",
74 75 "<commit_id>",
75 76 ...
76 77 ],
77 78 "review_status": "<review_status>",
78 79 "mergeable": {
79 80 "status": "<bool>",
80 81 "message": "<message>",
81 82 },
82 83 "source": {
83 84 "clone_url": "<clone_url>",
84 85 "repository": "<repository_name>",
85 86 "reference":
86 87 {
87 88 "name": "<name>",
88 89 "type": "<type>",
89 90 "commit_id": "<commit_id>",
90 91 }
91 92 },
92 93 "target": {
93 94 "clone_url": "<clone_url>",
94 95 "repository": "<repository_name>",
95 96 "reference":
96 97 {
97 98 "name": "<name>",
98 99 "type": "<type>",
99 100 "commit_id": "<commit_id>",
100 101 }
101 102 },
102 103 "merge": {
103 104 "clone_url": "<clone_url>",
104 105 "reference":
105 106 {
106 107 "name": "<name>",
107 108 "type": "<type>",
108 109 "commit_id": "<commit_id>",
109 110 }
110 111 },
111 112 "author": <user_obj>,
112 113 "reviewers": [
113 114 ...
114 115 {
115 116 "user": "<user_obj>",
116 117 "review_status": "<review_status>",
117 118 }
118 119 ...
119 120 ]
120 121 },
121 122 "error": null
122 123 """
123 124 get_repo_or_error(repoid)
124 125 pull_request = get_pull_request_or_error(pullrequestid)
125 126 if not PullRequestModel().check_user_read(
126 127 pull_request, apiuser, api=True):
127 128 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
128 129 data = pull_request.get_api_data()
129 130 return data
130 131
131 132
132 133 @jsonrpc_method()
133 134 def get_pull_requests(request, apiuser, repoid, status=Optional('new')):
134 135 """
135 136 Get all pull requests from the repository specified in `repoid`.
136 137
137 138 :param apiuser: This is filled automatically from the |authtoken|.
138 139 :type apiuser: AuthUser
139 140 :param repoid: Repository name or repository ID.
140 141 :type repoid: str or int
141 142 :param status: Only return pull requests with the specified status.
142 143 Valid options are.
143 144 * ``new`` (default)
144 145 * ``open``
145 146 * ``closed``
146 147 :type status: str
147 148
148 149 Example output:
149 150
150 151 .. code-block:: bash
151 152
152 153 "id": <id_given_in_input>,
153 154 "result":
154 155 [
155 156 ...
156 157 {
157 158 "pull_request_id": "<pull_request_id>",
158 159 "url": "<url>",
159 160 "title" : "<title>",
160 161 "description": "<description>",
161 162 "status": "<status>",
162 163 "created_on": "<date_time_created>",
163 164 "updated_on": "<date_time_updated>",
164 165 "commit_ids": [
165 166 ...
166 167 "<commit_id>",
167 168 "<commit_id>",
168 169 ...
169 170 ],
170 171 "review_status": "<review_status>",
171 172 "mergeable": {
172 173 "status": "<bool>",
173 174 "message: "<message>",
174 175 },
175 176 "source": {
176 177 "clone_url": "<clone_url>",
177 178 "reference":
178 179 {
179 180 "name": "<name>",
180 181 "type": "<type>",
181 182 "commit_id": "<commit_id>",
182 183 }
183 184 },
184 185 "target": {
185 186 "clone_url": "<clone_url>",
186 187 "reference":
187 188 {
188 189 "name": "<name>",
189 190 "type": "<type>",
190 191 "commit_id": "<commit_id>",
191 192 }
192 193 },
193 194 "merge": {
194 195 "clone_url": "<clone_url>",
195 196 "reference":
196 197 {
197 198 "name": "<name>",
198 199 "type": "<type>",
199 200 "commit_id": "<commit_id>",
200 201 }
201 202 },
202 203 "author": <user_obj>,
203 204 "reviewers": [
204 205 ...
205 206 {
206 207 "user": "<user_obj>",
207 208 "review_status": "<review_status>",
208 209 }
209 210 ...
210 211 ]
211 212 }
212 213 ...
213 214 ],
214 215 "error": null
215 216
216 217 """
217 218 repo = get_repo_or_error(repoid)
218 219 if not has_superadmin_permission(apiuser):
219 220 _perms = (
220 221 'repository.admin', 'repository.write', 'repository.read',)
221 222 validate_repo_permissions(apiuser, repoid, repo, _perms)
222 223
223 224 status = Optional.extract(status)
224 225 pull_requests = PullRequestModel().get_all(repo, statuses=[status])
225 226 data = [pr.get_api_data() for pr in pull_requests]
226 227 return data
227 228
228 229
229 230 @jsonrpc_method()
230 def merge_pull_request(request, apiuser, repoid, pullrequestid,
231 userid=Optional(OAttr('apiuser'))):
231 def merge_pull_request(
232 request, apiuser, repoid, pullrequestid,
233 userid=Optional(OAttr('apiuser'))):
232 234 """
233 235 Merge the pull request specified by `pullrequestid` into its target
234 236 repository.
235 237
236 238 :param apiuser: This is filled automatically from the |authtoken|.
237 239 :type apiuser: AuthUser
238 240 :param repoid: The Repository name or repository ID of the
239 241 target repository to which the |pr| is to be merged.
240 242 :type repoid: str or int
241 243 :param pullrequestid: ID of the pull request which shall be merged.
242 244 :type pullrequestid: int
243 245 :param userid: Merge the pull request as this user.
244 246 :type userid: Optional(str or int)
245 247
246 248 Example output:
247 249
248 250 .. code-block:: bash
249 251
250 252 "id": <id_given_in_input>,
251 253 "result": {
252 254 "executed": "<bool>",
253 255 "failure_reason": "<int>",
254 256 "merge_commit_id": "<merge_commit_id>",
255 257 "possible": "<bool>",
256 258 "merge_ref": {
257 259 "commit_id": "<commit_id>",
258 260 "type": "<type>",
259 261 "name": "<name>"
260 262 }
261 263 },
262 264 "error": null
263 265 """
264 266 repo = get_repo_or_error(repoid)
265 267 if not isinstance(userid, Optional):
266 268 if (has_superadmin_permission(apiuser) or
267 269 HasRepoPermissionAnyApi('repository.admin')(
268 270 user=apiuser, repo_name=repo.repo_name)):
269 271 apiuser = get_user_or_error(userid)
270 272 else:
271 273 raise JSONRPCError('userid is not the same as your user')
272 274
273 275 pull_request = get_pull_request_or_error(pullrequestid)
274 276
275 277 check = MergeCheck.validate(pull_request, user=apiuser)
276 278 merge_possible = not check.failed
277 279
278 280 if not merge_possible:
279 281 error_messages = []
280 282 for err_type, error_msg in check.errors:
281 283 error_msg = request.translate(error_msg)
282 284 error_messages.append(error_msg)
283 285
284 286 reasons = ','.join(error_messages)
285 287 raise JSONRPCError(
286 288 'merge not possible for following reasons: {}'.format(reasons))
287 289
288 290 target_repo = pull_request.target_repo
289 291 extras = vcs_operation_context(
290 292 request.environ, repo_name=target_repo.repo_name,
291 293 username=apiuser.username, action='push',
292 294 scm=target_repo.repo_type)
293 295 merge_response = PullRequestModel().merge(
294 296 pull_request, apiuser, extras=extras)
295 297 if merge_response.executed:
296 298 PullRequestModel().close_pull_request(
297 299 pull_request.pull_request_id, apiuser)
298 300
299 301 Session().commit()
300 302
301 303 # In previous versions the merge response directly contained the merge
302 304 # commit id. It is now contained in the merge reference object. To be
303 305 # backwards compatible we have to extract it again.
304 306 merge_response = merge_response._asdict()
305 307 merge_response['merge_commit_id'] = merge_response['merge_ref'].commit_id
306 308
307 309 return merge_response
308 310
309 311
310 312 @jsonrpc_method()
311 def close_pull_request(request, apiuser, repoid, pullrequestid,
312 userid=Optional(OAttr('apiuser'))):
313 """
314 Close the pull request specified by `pullrequestid`.
315
316 :param apiuser: This is filled automatically from the |authtoken|.
317 :type apiuser: AuthUser
318 :param repoid: Repository name or repository ID to which the pull
319 request belongs.
320 :type repoid: str or int
321 :param pullrequestid: ID of the pull request to be closed.
322 :type pullrequestid: int
323 :param userid: Close the pull request as this user.
324 :type userid: Optional(str or int)
325
326 Example output:
327
328 .. code-block:: bash
329
330 "id": <id_given_in_input>,
331 "result": {
332 "pull_request_id": "<int>",
333 "closed": "<bool>"
334 },
335 "error": null
336
337 """
338 repo = get_repo_or_error(repoid)
339 if not isinstance(userid, Optional):
340 if (has_superadmin_permission(apiuser) or
341 HasRepoPermissionAnyApi('repository.admin')(
342 user=apiuser, repo_name=repo.repo_name)):
343 apiuser = get_user_or_error(userid)
344 else:
345 raise JSONRPCError('userid is not the same as your user')
346
347 pull_request = get_pull_request_or_error(pullrequestid)
348 if not PullRequestModel().check_user_update(
349 pull_request, apiuser, api=True):
350 raise JSONRPCError(
351 'pull request `%s` close failed, no permission to close.' % (
352 pullrequestid,))
353 if pull_request.is_closed():
354 raise JSONRPCError(
355 'pull request `%s` is already closed' % (pullrequestid,))
356
357 PullRequestModel().close_pull_request(
358 pull_request.pull_request_id, apiuser)
359 Session().commit()
360 data = {
361 'pull_request_id': pull_request.pull_request_id,
362 'closed': True,
363 }
364 return data
365
366
367 @jsonrpc_method()
368 313 def comment_pull_request(
369 314 request, apiuser, repoid, pullrequestid, message=Optional(None),
370 315 commit_id=Optional(None), status=Optional(None),
371 316 comment_type=Optional(ChangesetComment.COMMENT_TYPE_NOTE),
372 317 resolves_comment_id=Optional(None),
373 318 userid=Optional(OAttr('apiuser'))):
374 319 """
375 320 Comment on the pull request specified with the `pullrequestid`,
376 321 in the |repo| specified by the `repoid`, and optionally change the
377 322 review status.
378 323
379 324 :param apiuser: This is filled automatically from the |authtoken|.
380 325 :type apiuser: AuthUser
381 326 :param repoid: The repository name or repository ID.
382 327 :type repoid: str or int
383 328 :param pullrequestid: The pull request ID.
384 329 :type pullrequestid: int
385 330 :param commit_id: Specify the commit_id for which to set a comment. If
386 331 given commit_id is different than latest in the PR status
387 332 change won't be performed.
388 333 :type commit_id: str
389 334 :param message: The text content of the comment.
390 335 :type message: str
391 336 :param status: (**Optional**) Set the approval status of the pull
392 337 request. One of: 'not_reviewed', 'approved', 'rejected',
393 338 'under_review'
394 339 :type status: str
395 340 :param comment_type: Comment type, one of: 'note', 'todo'
396 341 :type comment_type: Optional(str), default: 'note'
397 342 :param userid: Comment on the pull request as this user
398 343 :type userid: Optional(str or int)
399 344
400 345 Example output:
401 346
402 347 .. code-block:: bash
403 348
404 349 id : <id_given_in_input>
405 350 result : {
406 351 "pull_request_id": "<Integer>",
407 352 "comment_id": "<Integer>",
408 353 "status": {"given": <given_status>,
409 354 "was_changed": <bool status_was_actually_changed> },
410 355 },
411 356 error : null
412 357 """
413 358 repo = get_repo_or_error(repoid)
414 359 if not isinstance(userid, Optional):
415 360 if (has_superadmin_permission(apiuser) or
416 361 HasRepoPermissionAnyApi('repository.admin')(
417 362 user=apiuser, repo_name=repo.repo_name)):
418 363 apiuser = get_user_or_error(userid)
419 364 else:
420 365 raise JSONRPCError('userid is not the same as your user')
421 366
422 367 pull_request = get_pull_request_or_error(pullrequestid)
423 368 if not PullRequestModel().check_user_read(
424 369 pull_request, apiuser, api=True):
425 370 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
426 371 message = Optional.extract(message)
427 372 status = Optional.extract(status)
428 373 commit_id = Optional.extract(commit_id)
429 374 comment_type = Optional.extract(comment_type)
430 375 resolves_comment_id = Optional.extract(resolves_comment_id)
431 376
432 377 if not message and not status:
433 378 raise JSONRPCError(
434 379 'Both message and status parameters are missing. '
435 380 'At least one is required.')
436 381
437 382 if (status not in (st[0] for st in ChangesetStatus.STATUSES) and
438 383 status is not None):
439 384 raise JSONRPCError('Unknown comment status: `%s`' % status)
440 385
441 386 if commit_id and commit_id not in pull_request.revisions:
442 387 raise JSONRPCError(
443 388 'Invalid commit_id `%s` for this pull request.' % commit_id)
444 389
445 390 allowed_to_change_status = PullRequestModel().check_user_change_status(
446 391 pull_request, apiuser)
447 392
448 393 # if commit_id is passed re-validated if user is allowed to change status
449 394 # based on latest commit_id from the PR
450 395 if commit_id:
451 396 commit_idx = pull_request.revisions.index(commit_id)
452 397 if commit_idx != 0:
453 398 allowed_to_change_status = False
454 399
455 400 if resolves_comment_id:
456 401 comment = ChangesetComment.get(resolves_comment_id)
457 402 if not comment:
458 403 raise JSONRPCError(
459 404 'Invalid resolves_comment_id `%s` for this pull request.'
460 405 % resolves_comment_id)
461 406 if comment.comment_type != ChangesetComment.COMMENT_TYPE_TODO:
462 407 raise JSONRPCError(
463 408 'Comment `%s` is wrong type for setting status to resolved.'
464 409 % resolves_comment_id)
465 410
466 411 text = message
467 412 status_label = ChangesetStatus.get_status_lbl(status)
468 413 if status and allowed_to_change_status:
469 414 st_message = ('Status change %(transition_icon)s %(status)s'
470 415 % {'transition_icon': '>', 'status': status_label})
471 416 text = message or st_message
472 417
473 418 rc_config = SettingsModel().get_all_settings()
474 419 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
475 420
476 421 status_change = status and allowed_to_change_status
477 422 comment = CommentsModel().create(
478 423 text=text,
479 424 repo=pull_request.target_repo.repo_id,
480 425 user=apiuser.user_id,
481 426 pull_request=pull_request.pull_request_id,
482 427 f_path=None,
483 428 line_no=None,
484 429 status_change=(status_label if status_change else None),
485 430 status_change_type=(status if status_change else None),
486 431 closing_pr=False,
487 432 renderer=renderer,
488 433 comment_type=comment_type,
489 434 resolves_comment_id=resolves_comment_id
490 435 )
491 436
492 437 if allowed_to_change_status and status:
493 438 ChangesetStatusModel().set_status(
494 439 pull_request.target_repo.repo_id,
495 440 status,
496 441 apiuser.user_id,
497 442 comment,
498 443 pull_request=pull_request.pull_request_id
499 444 )
500 445 Session().flush()
501 446
502 447 Session().commit()
503 448 data = {
504 449 'pull_request_id': pull_request.pull_request_id,
505 450 'comment_id': comment.comment_id if comment else None,
506 451 'status': {'given': status, 'was_changed': status_change},
507 452 }
508 453 return data
509 454
510 455
511 456 @jsonrpc_method()
512 457 def create_pull_request(
513 458 request, apiuser, source_repo, target_repo, source_ref, target_ref,
514 459 title, description=Optional(''), reviewers=Optional(None)):
515 460 """
516 461 Creates a new pull request.
517 462
518 463 Accepts refs in the following formats:
519 464
520 465 * branch:<branch_name>:<sha>
521 466 * branch:<branch_name>
522 467 * bookmark:<bookmark_name>:<sha> (Mercurial only)
523 468 * bookmark:<bookmark_name> (Mercurial only)
524 469
525 470 :param apiuser: This is filled automatically from the |authtoken|.
526 471 :type apiuser: AuthUser
527 472 :param source_repo: Set the source repository name.
528 473 :type source_repo: str
529 474 :param target_repo: Set the target repository name.
530 475 :type target_repo: str
531 476 :param source_ref: Set the source ref name.
532 477 :type source_ref: str
533 478 :param target_ref: Set the target ref name.
534 479 :type target_ref: str
535 480 :param title: Set the pull request title.
536 481 :type title: str
537 482 :param description: Set the pull request description.
538 483 :type description: Optional(str)
539 484 :param reviewers: Set the new pull request reviewers list.
540 485 :type reviewers: Optional(list)
541 486 Accepts username strings or objects of the format:
542 487
543 488 {'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}
544 489 """
545 490
546 491 source = get_repo_or_error(source_repo)
547 492 target = get_repo_or_error(target_repo)
548 493 if not has_superadmin_permission(apiuser):
549 494 _perms = ('repository.admin', 'repository.write', 'repository.read',)
550 495 validate_repo_permissions(apiuser, source_repo, source, _perms)
551 496
552 497 full_source_ref = resolve_ref_or_error(source_ref, source)
553 498 full_target_ref = resolve_ref_or_error(target_ref, target)
554 499 source_commit = get_commit_or_error(full_source_ref, source)
555 500 target_commit = get_commit_or_error(full_target_ref, target)
556 501 source_scm = source.scm_instance()
557 502 target_scm = target.scm_instance()
558 503
559 504 commit_ranges = target_scm.compare(
560 505 target_commit.raw_id, source_commit.raw_id, source_scm,
561 506 merge=True, pre_load=[])
562 507
563 508 ancestor = target_scm.get_common_ancestor(
564 509 target_commit.raw_id, source_commit.raw_id, source_scm)
565 510
566 511 if not commit_ranges:
567 512 raise JSONRPCError('no commits found')
568 513
569 514 if not ancestor:
570 515 raise JSONRPCError('no common ancestor found')
571 516
572 517 reviewer_objects = Optional.extract(reviewers) or []
573 518 if reviewer_objects:
574 519 schema = ReviewerListSchema()
575 520 try:
576 521 reviewer_objects = schema.deserialize(reviewer_objects)
577 522 except Invalid as err:
578 523 raise JSONRPCValidationError(colander_exc=err)
579 524
580 525 reviewers = []
581 526 for reviewer_object in reviewer_objects:
582 527 user = get_user_or_error(reviewer_object['username'])
583 528 reasons = reviewer_object['reasons']
584 529 mandatory = reviewer_object['mandatory']
585 530 reviewers.append((user.user_id, reasons, mandatory))
586 531
587 532 pull_request_model = PullRequestModel()
588 533 pull_request = pull_request_model.create(
589 534 created_by=apiuser.user_id,
590 535 source_repo=source_repo,
591 536 source_ref=full_source_ref,
592 537 target_repo=target_repo,
593 538 target_ref=full_target_ref,
594 539 revisions=reversed(
595 540 [commit.raw_id for commit in reversed(commit_ranges)]),
596 541 reviewers=reviewers,
597 542 title=title,
598 543 description=Optional.extract(description)
599 544 )
600 545
601 546 Session().commit()
602 547 data = {
603 548 'msg': 'Created new pull request `{}`'.format(title),
604 549 'pull_request_id': pull_request.pull_request_id,
605 550 }
606 551 return data
607 552
608 553
609 554 @jsonrpc_method()
610 555 def update_pull_request(
611 556 request, apiuser, repoid, pullrequestid, title=Optional(''),
612 557 description=Optional(''), reviewers=Optional(None),
613 update_commits=Optional(None), close_pull_request=Optional(None)):
558 update_commits=Optional(None)):
614 559 """
615 560 Updates a pull request.
616 561
617 562 :param apiuser: This is filled automatically from the |authtoken|.
618 563 :type apiuser: AuthUser
619 564 :param repoid: The repository name or repository ID.
620 565 :type repoid: str or int
621 566 :param pullrequestid: The pull request ID.
622 567 :type pullrequestid: int
623 568 :param title: Set the pull request title.
624 569 :type title: str
625 570 :param description: Update pull request description.
626 571 :type description: Optional(str)
627 572 :param reviewers: Update pull request reviewers list with new value.
628 573 :type reviewers: Optional(list)
629 574 Accepts username strings or objects of the format:
630 575
631 576 {'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}
632 577
633 578 :param update_commits: Trigger update of commits for this pull request
634 579 :type: update_commits: Optional(bool)
635 :param close_pull_request: Close this pull request with rejected state
636 :type: close_pull_request: Optional(bool)
637 580
638 581 Example output:
639 582
640 583 .. code-block:: bash
641 584
642 585 id : <id_given_in_input>
643 586 result : {
644 587 "msg": "Updated pull request `63`",
645 588 "pull_request": <pull_request_object>,
646 589 "updated_reviewers": {
647 590 "added": [
648 591 "username"
649 592 ],
650 593 "removed": []
651 594 },
652 595 "updated_commits": {
653 596 "added": [
654 597 "<sha1_hash>"
655 598 ],
656 599 "common": [
657 600 "<sha1_hash>",
658 601 "<sha1_hash>",
659 602 ],
660 603 "removed": []
661 604 }
662 605 }
663 606 error : null
664 607 """
665 608
666 609 repo = get_repo_or_error(repoid)
667 610 pull_request = get_pull_request_or_error(pullrequestid)
668 611 if not PullRequestModel().check_user_update(
669 612 pull_request, apiuser, api=True):
670 613 raise JSONRPCError(
671 614 'pull request `%s` update failed, no permission to update.' % (
672 615 pullrequestid,))
673 616 if pull_request.is_closed():
674 617 raise JSONRPCError(
675 618 'pull request `%s` update failed, pull request is closed' % (
676 619 pullrequestid,))
677 620
678
679 621 reviewer_objects = Optional.extract(reviewers) or []
680 622 if reviewer_objects:
681 623 schema = ReviewerListSchema()
682 624 try:
683 625 reviewer_objects = schema.deserialize(reviewer_objects)
684 626 except Invalid as err:
685 627 raise JSONRPCValidationError(colander_exc=err)
686 628
687 629 reviewers = []
688 630 for reviewer_object in reviewer_objects:
689 631 user = get_user_or_error(reviewer_object['username'])
690 632 reasons = reviewer_object['reasons']
691 633 mandatory = reviewer_object['mandatory']
692 634 reviewers.append((user.user_id, reasons, mandatory))
693 635
694 636 title = Optional.extract(title)
695 637 description = Optional.extract(description)
696 638 if title or description:
697 639 PullRequestModel().edit(
698 640 pull_request, title or pull_request.title,
699 641 description or pull_request.description)
700 642 Session().commit()
701 643
702 644 commit_changes = {"added": [], "common": [], "removed": []}
703 645 if str2bool(Optional.extract(update_commits)):
704 646 if PullRequestModel().has_valid_update_type(pull_request):
705 647 update_response = PullRequestModel().update_commits(
706 648 pull_request)
707 649 commit_changes = update_response.changes or commit_changes
708 650 Session().commit()
709 651
710 652 reviewers_changes = {"added": [], "removed": []}
711 653 if reviewers:
712 654 added_reviewers, removed_reviewers = \
713 655 PullRequestModel().update_reviewers(pull_request, reviewers)
714 656
715 657 reviewers_changes['added'] = sorted(
716 658 [get_user_or_error(n).username for n in added_reviewers])
717 659 reviewers_changes['removed'] = sorted(
718 660 [get_user_or_error(n).username for n in removed_reviewers])
719 661 Session().commit()
720 662
721 if str2bool(Optional.extract(close_pull_request)):
722 PullRequestModel().close_pull_request_with_comment(
723 pull_request, apiuser, repo)
724 Session().commit()
725
726 663 data = {
727 664 'msg': 'Updated pull request `{}`'.format(
728 665 pull_request.pull_request_id),
729 666 'pull_request': pull_request.get_api_data(),
730 667 'updated_commits': commit_changes,
731 668 'updated_reviewers': reviewers_changes
732 669 }
733 670
734 671 return data
672
673
674 @jsonrpc_method()
675 def close_pull_request(
676 request, apiuser, repoid, pullrequestid,
677 userid=Optional(OAttr('apiuser')), message=Optional('')):
678 """
679 Close the pull request specified by `pullrequestid`.
680
681 :param apiuser: This is filled automatically from the |authtoken|.
682 :type apiuser: AuthUser
683 :param repoid: Repository name or repository ID to which the pull
684 request belongs.
685 :type repoid: str or int
686 :param pullrequestid: ID of the pull request to be closed.
687 :type pullrequestid: int
688 :param userid: Close the pull request as this user.
689 :type userid: Optional(str or int)
690 :param message: Optional message to close the Pull Request with. If not
691 specified it will be generated automatically.
692 :type message: Optional(str)
693
694 Example output:
695
696 .. code-block:: bash
697
698 "id": <id_given_in_input>,
699 "result": {
700 "pull_request_id": "<int>",
701 "close_status": "<str:status_lbl>,
702 "closed": "<bool>"
703 },
704 "error": null
705
706 """
707 _ = request.translate
708
709 repo = get_repo_or_error(repoid)
710 if not isinstance(userid, Optional):
711 if (has_superadmin_permission(apiuser) or
712 HasRepoPermissionAnyApi('repository.admin')(
713 user=apiuser, repo_name=repo.repo_name)):
714 apiuser = get_user_or_error(userid)
715 else:
716 raise JSONRPCError('userid is not the same as your user')
717
718 pull_request = get_pull_request_or_error(pullrequestid)
719
720 if pull_request.is_closed():
721 raise JSONRPCError(
722 'pull request `%s` is already closed' % (pullrequestid,))
723
724 # only owner or admin or person with write permissions
725 allowed_to_close = PullRequestModel().check_user_update(
726 pull_request, apiuser, api=True)
727
728 if not allowed_to_close:
729 raise JSONRPCError(
730 'pull request `%s` close failed, no permission to close.' % (
731 pullrequestid,))
732
733 # message we're using to close the PR, else it's automatically generated
734 message = Optional.extract(message)
735
736 # finally close the PR, with proper message comment
737 comment, status = PullRequestModel().close_pull_request_with_comment(
738 pull_request, apiuser, repo, message=message)
739 status_lbl = ChangesetStatus.get_status_lbl(status)
740
741 Session().commit()
742
743 data = {
744 'pull_request_id': pull_request.pull_request_id,
745 'close_status': status_lbl,
746 'closed': True,
747 }
748 return data
@@ -1,486 +1,485 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 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 c.unresolved_comments = []
212 212 if len(c.commit_ranges) == 1:
213 213 commit = c.commit_ranges[0]
214 214 c.comments = CommentsModel().get_comments(
215 215 c.rhodecode_db_repo.repo_id,
216 216 revision=commit.raw_id)
217 217 c.statuses.append(ChangesetStatusModel().get_status(
218 218 c.rhodecode_db_repo.repo_id, commit.raw_id))
219 219 # comments from PR
220 220 statuses = ChangesetStatusModel().get_statuses(
221 221 c.rhodecode_db_repo.repo_id, commit.raw_id,
222 222 with_revisions=True)
223 223 prs = set(st.pull_request for st in statuses
224 224 if st.pull_request is not None)
225 225 # from associated statuses, check the pull requests, and
226 226 # show comments from them
227 227 for pr in prs:
228 228 c.comments.extend(pr.comments)
229 229
230 230 c.unresolved_comments = CommentsModel()\
231 231 .get_commit_unresolved_todos(commit.raw_id)
232 232
233 233 # Iterate over ranges (default commit view is always one commit)
234 234 for commit in c.commit_ranges:
235 235 c.changes[commit.raw_id] = []
236 236
237 237 commit2 = commit
238 238 commit1 = commit.parents[0] if commit.parents else EmptyCommit()
239 239
240 240 _diff = c.rhodecode_repo.get_diff(
241 241 commit1, commit2,
242 242 ignore_whitespace=ign_whitespace_lcl, context=context_lcl)
243 243 diff_processor = diffs.DiffProcessor(
244 244 _diff, format='newdiff', diff_limit=diff_limit,
245 245 file_limit=file_limit, show_full_diff=fulldiff)
246 246
247 247 commit_changes = OrderedDict()
248 248 if method == 'show':
249 249 _parsed = diff_processor.prepare()
250 250 c.limited_diff = isinstance(_parsed, diffs.LimitedDiffContainer)
251 251
252 252 _parsed = diff_processor.prepare()
253 253
254 254 def _node_getter(commit):
255 255 def get_node(fname):
256 256 try:
257 257 return commit.get_node(fname)
258 258 except NodeDoesNotExistError:
259 259 return None
260 260 return get_node
261 261
262 262 inline_comments = CommentsModel().get_inline_comments(
263 263 c.rhodecode_db_repo.repo_id, revision=commit.raw_id)
264 264 c.inline_cnt = CommentsModel().get_inline_comments_count(
265 265 inline_comments)
266 266
267 267 diffset = codeblocks.DiffSet(
268 268 repo_name=c.repo_name,
269 269 source_node_getter=_node_getter(commit1),
270 270 target_node_getter=_node_getter(commit2),
271 271 comments=inline_comments
272 272 ).render_patchset(_parsed, commit1.raw_id, commit2.raw_id)
273 273 c.changes[commit.raw_id] = diffset
274 274 else:
275 275 # downloads/raw we only need RAW diff nothing else
276 276 diff = diff_processor.as_raw()
277 277 c.changes[commit.raw_id] = [None, None, None, None, diff, None, None]
278 278
279 279 # sort comments by how they were generated
280 280 c.comments = sorted(c.comments, key=lambda x: x.comment_id)
281 281
282 282 if len(c.commit_ranges) == 1:
283 283 c.commit = c.commit_ranges[0]
284 284 c.parent_tmpl = ''.join(
285 285 '# Parent %s\n' % x.raw_id for x in c.commit.parents)
286 286 if method == 'download':
287 287 response.content_type = 'text/plain'
288 288 response.content_disposition = (
289 289 'attachment; filename=%s.diff' % commit_id_range[:12])
290 290 return diff
291 291 elif method == 'patch':
292 292 response.content_type = 'text/plain'
293 293 c.diff = safe_unicode(diff)
294 294 return render('changeset/patch_changeset.mako')
295 295 elif method == 'raw':
296 296 response.content_type = 'text/plain'
297 297 return diff
298 298 elif method == 'show':
299 299 if len(c.commit_ranges) == 1:
300 300 return render('changeset/changeset.mako')
301 301 else:
302 302 c.ancestor = None
303 303 c.target_repo = c.rhodecode_db_repo
304 304 return render('changeset/changeset_range.mako')
305 305
306 306 @LoginRequired()
307 307 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
308 308 'repository.admin')
309 309 def index(self, revision, method='show'):
310 310 return self._index(revision, method=method)
311 311
312 312 @LoginRequired()
313 313 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
314 314 'repository.admin')
315 315 def changeset_raw(self, revision):
316 316 return self._index(revision, method='raw')
317 317
318 318 @LoginRequired()
319 319 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
320 320 'repository.admin')
321 321 def changeset_patch(self, revision):
322 322 return self._index(revision, method='patch')
323 323
324 324 @LoginRequired()
325 325 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
326 326 'repository.admin')
327 327 def changeset_download(self, revision):
328 328 return self._index(revision, method='download')
329 329
330 330 @LoginRequired()
331 331 @NotAnonymous()
332 332 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
333 333 'repository.admin')
334 334 @auth.CSRFRequired()
335 335 @jsonify
336 336 def comment(self, repo_name, revision):
337 337 commit_id = revision
338 338 status = request.POST.get('changeset_status', None)
339 339 text = request.POST.get('text')
340 340 comment_type = request.POST.get('comment_type')
341 341 resolves_comment_id = request.POST.get('resolves_comment_id', None)
342 342
343 343 if status:
344 344 text = text or (_('Status change %(transition_icon)s %(status)s')
345 345 % {'transition_icon': '>',
346 346 'status': ChangesetStatus.get_status_lbl(status)})
347 347
348 348 multi_commit_ids = []
349 349 for _commit_id in request.POST.get('commit_ids', '').split(','):
350 350 if _commit_id not in ['', None, EmptyCommit.raw_id]:
351 351 if _commit_id not in multi_commit_ids:
352 352 multi_commit_ids.append(_commit_id)
353 353
354 354 commit_ids = multi_commit_ids or [commit_id]
355 355
356 356 comment = None
357 357 for current_id in filter(None, commit_ids):
358 358 c.co = comment = CommentsModel().create(
359 359 text=text,
360 360 repo=c.rhodecode_db_repo.repo_id,
361 361 user=c.rhodecode_user.user_id,
362 362 commit_id=current_id,
363 363 f_path=request.POST.get('f_path'),
364 364 line_no=request.POST.get('line'),
365 365 status_change=(ChangesetStatus.get_status_lbl(status)
366 366 if status else None),
367 367 status_change_type=status,
368 368 comment_type=comment_type,
369 369 resolves_comment_id=resolves_comment_id
370 370 )
371 c.inline_comment = True if comment.line_no else False
372 371
373 372 # get status if set !
374 373 if status:
375 374 # if latest status was from pull request and it's closed
376 375 # disallow changing status !
377 376 # dont_allow_on_closed_pull_request = True !
378 377
379 378 try:
380 379 ChangesetStatusModel().set_status(
381 380 c.rhodecode_db_repo.repo_id,
382 381 status,
383 382 c.rhodecode_user.user_id,
384 383 comment,
385 384 revision=current_id,
386 385 dont_allow_on_closed_pull_request=True
387 386 )
388 387 except StatusChangeOnClosedPullRequestError:
389 388 msg = _('Changing the status of a commit associated with '
390 389 'a closed pull request is not allowed')
391 390 log.exception(msg)
392 391 h.flash(msg, category='warning')
393 392 return redirect(h.url(
394 393 'changeset_home', repo_name=repo_name,
395 394 revision=current_id))
396 395
397 396 # finalize, commit and redirect
398 397 Session().commit()
399 398
400 399 data = {
401 400 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
402 401 }
403 402 if comment:
404 403 data.update(comment.get_dict())
405 404 data.update({'rendered_text':
406 405 render('changeset/changeset_comment_block.mako')})
407 406
408 407 return data
409 408
410 409 @LoginRequired()
411 410 @NotAnonymous()
412 411 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
413 412 'repository.admin')
414 413 @auth.CSRFRequired()
415 414 def preview_comment(self):
416 415 # Technically a CSRF token is not needed as no state changes with this
417 416 # call. However, as this is a POST is better to have it, so automated
418 417 # tools don't flag it as potential CSRF.
419 418 # Post is required because the payload could be bigger than the maximum
420 419 # allowed by GET.
421 420 if not request.environ.get('HTTP_X_PARTIAL_XHR'):
422 421 raise HTTPBadRequest()
423 422 text = request.POST.get('text')
424 423 renderer = request.POST.get('renderer') or 'rst'
425 424 if text:
426 425 return h.render(text, renderer=renderer, mentions=True)
427 426 return ''
428 427
429 428 @LoginRequired()
430 429 @NotAnonymous()
431 430 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
432 431 'repository.admin')
433 432 @auth.CSRFRequired()
434 433 @jsonify
435 434 def delete_comment(self, repo_name, comment_id):
436 435 comment = ChangesetComment.get(comment_id)
437 436 if not comment:
438 437 log.debug('Comment with id:%s not found, skipping', comment_id)
439 438 # comment already deleted in another call probably
440 439 return True
441 440
442 441 owner = (comment.author.user_id == c.rhodecode_user.user_id)
443 442 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
444 443 if h.HasPermissionAny('hg.admin')() or is_repo_admin or owner:
445 444 CommentsModel().delete(comment=comment)
446 445 Session().commit()
447 446 return True
448 447 else:
449 448 raise HTTPForbidden()
450 449
451 450 @LoginRequired()
452 451 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
453 452 'repository.admin')
454 453 @jsonify
455 454 def changeset_info(self, repo_name, revision):
456 455 if request.is_xhr:
457 456 try:
458 457 return c.rhodecode_repo.get_commit(commit_id=revision)
459 458 except CommitDoesNotExistError as e:
460 459 return EmptyCommit(message=str(e))
461 460 else:
462 461 raise HTTPBadRequest()
463 462
464 463 @LoginRequired()
465 464 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
466 465 'repository.admin')
467 466 @jsonify
468 467 def changeset_children(self, repo_name, revision):
469 468 if request.is_xhr:
470 469 commit = c.rhodecode_repo.get_commit(commit_id=revision)
471 470 result = {"results": commit.children}
472 471 return result
473 472 else:
474 473 raise HTTPBadRequest()
475 474
476 475 @LoginRequired()
477 476 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
478 477 'repository.admin')
479 478 @jsonify
480 479 def changeset_parents(self, repo_name, revision):
481 480 if request.is_xhr:
482 481 commit = c.rhodecode_repo.get_commit(commit_id=revision)
483 482 result = {"results": commit.parents}
484 483 return result
485 484 else:
486 485 raise HTTPBadRequest()
@@ -1,1023 +1,1008 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 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, MergeCheck
64 64
65 65 log = logging.getLogger(__name__)
66 66
67 67
68 68 class PullrequestsController(BaseRepoController):
69 69
70 70 def __before__(self):
71 71 super(PullrequestsController, self).__before__()
72 72 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
73 73 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
74 74
75 75 @LoginRequired()
76 76 @NotAnonymous()
77 77 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
78 78 'repository.admin')
79 79 @HasAcceptedRepoType('git', 'hg')
80 80 def index(self):
81 81 source_repo = c.rhodecode_db_repo
82 82
83 83 try:
84 84 source_repo.scm_instance().get_commit()
85 85 except EmptyRepositoryError:
86 86 h.flash(h.literal(_('There are no commits yet')),
87 87 category='warning')
88 88 redirect(h.route_path('repo_summary', repo_name=source_repo.repo_name))
89 89
90 90 commit_id = request.GET.get('commit')
91 91 branch_ref = request.GET.get('branch')
92 92 bookmark_ref = request.GET.get('bookmark')
93 93
94 94 try:
95 95 source_repo_data = PullRequestModel().generate_repo_data(
96 96 source_repo, commit_id=commit_id,
97 97 branch=branch_ref, bookmark=bookmark_ref)
98 98 except CommitDoesNotExistError as e:
99 99 log.exception(e)
100 100 h.flash(_('Commit does not exist'), 'error')
101 101 redirect(url('pullrequest_home', repo_name=source_repo.repo_name))
102 102
103 103 default_target_repo = source_repo
104 104
105 105 if source_repo.parent:
106 106 parent_vcs_obj = source_repo.parent.scm_instance()
107 107 if parent_vcs_obj and not parent_vcs_obj.is_empty():
108 108 # change default if we have a parent repo
109 109 default_target_repo = source_repo.parent
110 110
111 111 target_repo_data = PullRequestModel().generate_repo_data(
112 112 default_target_repo)
113 113
114 114 selected_source_ref = source_repo_data['refs']['selected_ref']
115 115
116 116 title_source_ref = selected_source_ref.split(':', 2)[1]
117 117 c.default_title = PullRequestModel().generate_pullrequest_title(
118 118 source=source_repo.repo_name,
119 119 source_ref=title_source_ref,
120 120 target=default_target_repo.repo_name
121 121 )
122 122
123 123 c.default_repo_data = {
124 124 'source_repo_name': source_repo.repo_name,
125 125 'source_refs_json': json.dumps(source_repo_data),
126 126 'target_repo_name': default_target_repo.repo_name,
127 127 'target_refs_json': json.dumps(target_repo_data),
128 128 }
129 129 c.default_source_ref = selected_source_ref
130 130
131 131 return render('/pullrequests/pullrequest.mako')
132 132
133 133 @LoginRequired()
134 134 @NotAnonymous()
135 135 @XHRRequired()
136 136 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
137 137 'repository.admin')
138 138 @jsonify
139 139 def get_repo_refs(self, repo_name, target_repo_name):
140 140 repo = Repository.get_by_repo_name(target_repo_name)
141 141 if not repo:
142 142 raise HTTPNotFound
143 143 return PullRequestModel().generate_repo_data(repo)
144 144
145 145 @LoginRequired()
146 146 @NotAnonymous()
147 147 @XHRRequired()
148 148 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
149 149 'repository.admin')
150 150 @jsonify
151 151 def get_repo_destinations(self, repo_name):
152 152 repo = Repository.get_by_repo_name(repo_name)
153 153 if not repo:
154 154 raise HTTPNotFound
155 155 filter_query = request.GET.get('query')
156 156
157 157 query = Repository.query() \
158 158 .order_by(func.length(Repository.repo_name)) \
159 159 .filter(or_(
160 160 Repository.repo_name == repo.repo_name,
161 161 Repository.fork_id == repo.repo_id))
162 162
163 163 if filter_query:
164 164 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
165 165 query = query.filter(
166 166 Repository.repo_name.ilike(ilike_expression))
167 167
168 168 add_parent = False
169 169 if repo.parent:
170 170 if filter_query in repo.parent.repo_name:
171 171 parent_vcs_obj = repo.parent.scm_instance()
172 172 if parent_vcs_obj and not parent_vcs_obj.is_empty():
173 173 add_parent = True
174 174
175 175 limit = 20 - 1 if add_parent else 20
176 176 all_repos = query.limit(limit).all()
177 177 if add_parent:
178 178 all_repos += [repo.parent]
179 179
180 180 repos = []
181 181 for obj in self.scm_model.get_repos(all_repos):
182 182 repos.append({
183 183 'id': obj['name'],
184 184 'text': obj['name'],
185 185 'type': 'repo',
186 186 'obj': obj['dbrepo']
187 187 })
188 188
189 189 data = {
190 190 'more': False,
191 191 'results': [{
192 192 'text': _('Repositories'),
193 193 'children': repos
194 194 }] if repos else []
195 195 }
196 196 return data
197 197
198 198 @LoginRequired()
199 199 @NotAnonymous()
200 200 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
201 201 'repository.admin')
202 202 @HasAcceptedRepoType('git', 'hg')
203 203 @auth.CSRFRequired()
204 204 def create(self, repo_name):
205 205 repo = Repository.get_by_repo_name(repo_name)
206 206 if not repo:
207 207 raise HTTPNotFound
208 208
209 209 controls = peppercorn.parse(request.POST.items())
210 210
211 211 try:
212 212 _form = PullRequestForm(repo.repo_id)().to_python(controls)
213 213 except formencode.Invalid as errors:
214 214 if errors.error_dict.get('revisions'):
215 215 msg = 'Revisions: %s' % errors.error_dict['revisions']
216 216 elif errors.error_dict.get('pullrequest_title'):
217 217 msg = _('Pull request requires a title with min. 3 chars')
218 218 else:
219 219 msg = _('Error creating pull request: {}').format(errors)
220 220 log.exception(msg)
221 221 h.flash(msg, 'error')
222 222
223 223 # would rather just go back to form ...
224 224 return redirect(url('pullrequest_home', repo_name=repo_name))
225 225
226 226 source_repo = _form['source_repo']
227 227 source_ref = _form['source_ref']
228 228 target_repo = _form['target_repo']
229 229 target_ref = _form['target_ref']
230 230 commit_ids = _form['revisions'][::-1]
231 231
232 232 # find the ancestor for this pr
233 233 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
234 234 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
235 235
236 236 source_scm = source_db_repo.scm_instance()
237 237 target_scm = target_db_repo.scm_instance()
238 238
239 239 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
240 240 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
241 241
242 242 ancestor = source_scm.get_common_ancestor(
243 243 source_commit.raw_id, target_commit.raw_id, target_scm)
244 244
245 245 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
246 246 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
247 247
248 248 pullrequest_title = _form['pullrequest_title']
249 249 title_source_ref = source_ref.split(':', 2)[1]
250 250 if not pullrequest_title:
251 251 pullrequest_title = PullRequestModel().generate_pullrequest_title(
252 252 source=source_repo,
253 253 source_ref=title_source_ref,
254 254 target=target_repo
255 255 )
256 256
257 257 description = _form['pullrequest_desc']
258 258
259 259 get_default_reviewers_data, validate_default_reviewers = \
260 260 PullRequestModel().get_reviewer_functions()
261 261
262 262 # recalculate reviewers logic, to make sure we can validate this
263 263 reviewer_rules = get_default_reviewers_data(
264 264 c.rhodecode_user.get_instance(), source_db_repo,
265 265 source_commit, target_db_repo, target_commit)
266 266
267 267 reviewers = validate_default_reviewers(
268 268 _form['review_members'], reviewer_rules)
269 269
270 270 try:
271 271 pull_request = PullRequestModel().create(
272 272 c.rhodecode_user.user_id, source_repo, source_ref, target_repo,
273 273 target_ref, commit_ids, reviewers, pullrequest_title,
274 274 description, reviewer_rules
275 275 )
276 276 Session().commit()
277 277 h.flash(_('Successfully opened new pull request'),
278 278 category='success')
279 279 except Exception as e:
280 280 msg = _('Error occurred during creation of this pull request.')
281 281 log.exception(msg)
282 282 h.flash(msg, category='error')
283 283 return redirect(url('pullrequest_home', repo_name=repo_name))
284 284
285 285 return redirect(url('pullrequest_show', repo_name=target_repo,
286 286 pull_request_id=pull_request.pull_request_id))
287 287
288 288 @LoginRequired()
289 289 @NotAnonymous()
290 290 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
291 291 'repository.admin')
292 292 @auth.CSRFRequired()
293 293 @jsonify
294 294 def update(self, repo_name, pull_request_id):
295 295 pull_request_id = safe_int(pull_request_id)
296 296 pull_request = PullRequest.get_or_404(pull_request_id)
297 297 # only owner or admin can update it
298 298 allowed_to_update = PullRequestModel().check_user_update(
299 299 pull_request, c.rhodecode_user)
300 300 if allowed_to_update:
301 301 controls = peppercorn.parse(request.POST.items())
302 302
303 303 if 'review_members' in controls:
304 304 self._update_reviewers(
305 305 pull_request_id, controls['review_members'],
306 306 pull_request.reviewer_data)
307 307 elif str2bool(request.POST.get('update_commits', 'false')):
308 308 self._update_commits(pull_request)
309 elif str2bool(request.POST.get('close_pull_request', 'false')):
310 self._reject_close(pull_request)
311 309 elif str2bool(request.POST.get('edit_pull_request', 'false')):
312 310 self._edit_pull_request(pull_request)
313 311 else:
314 312 raise HTTPBadRequest()
315 313 return True
316 314 raise HTTPForbidden()
317 315
318 316 def _edit_pull_request(self, pull_request):
319 317 try:
320 318 PullRequestModel().edit(
321 319 pull_request, request.POST.get('title'),
322 320 request.POST.get('description'))
323 321 except ValueError:
324 322 msg = _(u'Cannot update closed pull requests.')
325 323 h.flash(msg, category='error')
326 324 return
327 325 else:
328 326 Session().commit()
329 327
330 328 msg = _(u'Pull request title & description updated.')
331 329 h.flash(msg, category='success')
332 330 return
333 331
334 332 def _update_commits(self, pull_request):
335 333 resp = PullRequestModel().update_commits(pull_request)
336 334
337 335 if resp.executed:
338 336
339 337 if resp.target_changed and resp.source_changed:
340 338 changed = 'target and source repositories'
341 339 elif resp.target_changed and not resp.source_changed:
342 340 changed = 'target repository'
343 341 elif not resp.target_changed and resp.source_changed:
344 342 changed = 'source repository'
345 343 else:
346 344 changed = 'nothing'
347 345
348 346 msg = _(
349 347 u'Pull request updated to "{source_commit_id}" with '
350 348 u'{count_added} added, {count_removed} removed commits. '
351 349 u'Source of changes: {change_source}')
352 350 msg = msg.format(
353 351 source_commit_id=pull_request.source_ref_parts.commit_id,
354 352 count_added=len(resp.changes.added),
355 353 count_removed=len(resp.changes.removed),
356 354 change_source=changed)
357 355 h.flash(msg, category='success')
358 356
359 357 registry = get_current_registry()
360 358 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
361 359 channelstream_config = rhodecode_plugins.get('channelstream', {})
362 360 if channelstream_config.get('enabled'):
363 361 message = msg + (
364 362 ' - <a onclick="window.location.reload()">'
365 363 '<strong>{}</strong></a>'.format(_('Reload page')))
366 364 channel = '/repo${}$/pr/{}'.format(
367 365 pull_request.target_repo.repo_name,
368 366 pull_request.pull_request_id
369 367 )
370 368 payload = {
371 369 'type': 'message',
372 370 'user': 'system',
373 371 'exclude_users': [request.user.username],
374 372 'channel': channel,
375 373 'message': {
376 374 'message': message,
377 375 'level': 'success',
378 376 'topic': '/notifications'
379 377 }
380 378 }
381 379 channelstream_request(
382 380 channelstream_config, [payload], '/message',
383 381 raise_exc=False)
384 382 else:
385 383 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
386 384 warning_reasons = [
387 385 UpdateFailureReason.NO_CHANGE,
388 386 UpdateFailureReason.WRONG_REF_TYPE,
389 387 ]
390 388 category = 'warning' if resp.reason in warning_reasons else 'error'
391 389 h.flash(msg, category=category)
392 390
393 391 @auth.CSRFRequired()
394 392 @LoginRequired()
395 393 @NotAnonymous()
396 394 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
397 395 'repository.admin')
398 396 def merge(self, repo_name, pull_request_id):
399 397 """
400 398 POST /{repo_name}/pull-request/{pull_request_id}
401 399
402 400 Merge will perform a server-side merge of the specified
403 401 pull request, if the pull request is approved and mergeable.
404 402 After successful merging, the pull request is automatically
405 403 closed, with a relevant comment.
406 404 """
407 405 pull_request_id = safe_int(pull_request_id)
408 406 pull_request = PullRequest.get_or_404(pull_request_id)
409 407 user = c.rhodecode_user
410 408
411 409 check = MergeCheck.validate(pull_request, user)
412 410 merge_possible = not check.failed
413 411
414 412 for err_type, error_msg in check.errors:
415 413 h.flash(error_msg, category=err_type)
416 414
417 415 if merge_possible:
418 416 log.debug("Pre-conditions checked, trying to merge.")
419 417 extras = vcs_operation_context(
420 418 request.environ, repo_name=pull_request.target_repo.repo_name,
421 419 username=user.username, action='push',
422 420 scm=pull_request.target_repo.repo_type)
423 421 self._merge_pull_request(pull_request, user, extras)
424 422
425 423 return redirect(url(
426 424 'pullrequest_show',
427 425 repo_name=pull_request.target_repo.repo_name,
428 426 pull_request_id=pull_request.pull_request_id))
429 427
430 428 def _merge_pull_request(self, pull_request, user, extras):
431 429 merge_resp = PullRequestModel().merge(
432 430 pull_request, user, extras=extras)
433 431
434 432 if merge_resp.executed:
435 433 log.debug("The merge was successful, closing the pull request.")
436 434 PullRequestModel().close_pull_request(
437 435 pull_request.pull_request_id, user)
438 436 Session().commit()
439 437 msg = _('Pull request was successfully merged and closed.')
440 438 h.flash(msg, category='success')
441 439 else:
442 440 log.debug(
443 441 "The merge was not successful. Merge response: %s",
444 442 merge_resp)
445 443 msg = PullRequestModel().merge_status_message(
446 444 merge_resp.failure_reason)
447 445 h.flash(msg, category='error')
448 446
449 447 def _update_reviewers(self, pull_request_id, review_members, reviewer_rules):
450 448
451 449 get_default_reviewers_data, validate_default_reviewers = \
452 450 PullRequestModel().get_reviewer_functions()
453 451
454 452 try:
455 453 reviewers = validate_default_reviewers(review_members, reviewer_rules)
456 454 except ValueError as e:
457 455 log.error('Reviewers Validation:{}'.format(e))
458 456 h.flash(e, category='error')
459 457 return
460 458
461 459 PullRequestModel().update_reviewers(pull_request_id, reviewers)
462 460 h.flash(_('Pull request reviewers updated.'), category='success')
463 461 Session().commit()
464 462
465 def _reject_close(self, pull_request):
466 if pull_request.is_closed():
467 raise HTTPForbidden()
468
469 PullRequestModel().close_pull_request_with_comment(
470 pull_request, c.rhodecode_user, c.rhodecode_db_repo)
471 Session().commit()
472
473 463 @LoginRequired()
474 464 @NotAnonymous()
475 465 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
476 466 'repository.admin')
477 467 @auth.CSRFRequired()
478 468 @jsonify
479 469 def delete(self, repo_name, pull_request_id):
480 470 pull_request_id = safe_int(pull_request_id)
481 471 pull_request = PullRequest.get_or_404(pull_request_id)
482 472
483 473 pr_closed = pull_request.is_closed()
484 474 allowed_to_delete = PullRequestModel().check_user_delete(
485 475 pull_request, c.rhodecode_user) and not pr_closed
486 476
487 477 # only owner can delete it !
488 478 if allowed_to_delete:
489 479 PullRequestModel().delete(pull_request)
490 480 Session().commit()
491 481 h.flash(_('Successfully deleted pull request'),
492 482 category='success')
493 483 return redirect(url('my_account_pullrequests'))
494 484
495 485 h.flash(_('Your are not allowed to delete this pull request'),
496 486 category='error')
497 487 raise HTTPForbidden()
498 488
499 489 def _get_pr_version(self, pull_request_id, version=None):
500 490 pull_request_id = safe_int(pull_request_id)
501 491 at_version = None
502 492
503 493 if version and version == 'latest':
504 494 pull_request_ver = PullRequest.get(pull_request_id)
505 495 pull_request_obj = pull_request_ver
506 496 _org_pull_request_obj = pull_request_obj
507 497 at_version = 'latest'
508 498 elif version:
509 499 pull_request_ver = PullRequestVersion.get_or_404(version)
510 500 pull_request_obj = pull_request_ver
511 501 _org_pull_request_obj = pull_request_ver.pull_request
512 502 at_version = pull_request_ver.pull_request_version_id
513 503 else:
514 504 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
515 505 pull_request_id)
516 506
517 507 pull_request_display_obj = PullRequest.get_pr_display_object(
518 508 pull_request_obj, _org_pull_request_obj)
519 509
520 510 return _org_pull_request_obj, pull_request_obj, \
521 511 pull_request_display_obj, at_version
522 512
523 513 def _get_diffset(
524 514 self, source_repo, source_ref_id, target_ref_id, target_commit,
525 515 source_commit, diff_limit, file_limit, display_inline_comments):
526 516 vcs_diff = PullRequestModel().get_diff(
527 517 source_repo, source_ref_id, target_ref_id)
528 518
529 519 diff_processor = diffs.DiffProcessor(
530 520 vcs_diff, format='newdiff', diff_limit=diff_limit,
531 521 file_limit=file_limit, show_full_diff=c.fulldiff)
532 522
533 523 _parsed = diff_processor.prepare()
534 524
535 525 def _node_getter(commit):
536 526 def get_node(fname):
537 527 try:
538 528 return commit.get_node(fname)
539 529 except NodeDoesNotExistError:
540 530 return None
541 531
542 532 return get_node
543 533
544 534 diffset = codeblocks.DiffSet(
545 535 repo_name=c.repo_name,
546 536 source_repo_name=c.source_repo.repo_name,
547 537 source_node_getter=_node_getter(target_commit),
548 538 target_node_getter=_node_getter(source_commit),
549 539 comments=display_inline_comments
550 540 )
551 541 diffset = diffset.render_patchset(
552 542 _parsed, target_commit.raw_id, source_commit.raw_id)
553 543
554 544 return diffset
555 545
556 546 @LoginRequired()
557 547 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
558 548 'repository.admin')
559 549 def show(self, repo_name, pull_request_id):
560 550 pull_request_id = safe_int(pull_request_id)
561 551 version = request.GET.get('version')
562 552 from_version = request.GET.get('from_version') or version
563 553 merge_checks = request.GET.get('merge_checks')
564 554 c.fulldiff = str2bool(request.GET.get('fulldiff'))
565 555
566 556 (pull_request_latest,
567 557 pull_request_at_ver,
568 558 pull_request_display_obj,
569 559 at_version) = self._get_pr_version(
570 560 pull_request_id, version=version)
571 561 pr_closed = pull_request_latest.is_closed()
572 562
573 563 if pr_closed and (version or from_version):
574 564 # not allow to browse versions
575 565 return redirect(h.url('pullrequest_show', repo_name=repo_name,
576 566 pull_request_id=pull_request_id))
577 567
578 568 versions = pull_request_display_obj.versions()
579 569
580 570 c.at_version = at_version
581 571 c.at_version_num = (at_version
582 572 if at_version and at_version != 'latest'
583 573 else None)
584 574 c.at_version_pos = ChangesetComment.get_index_from_version(
585 575 c.at_version_num, versions)
586 576
587 577 (prev_pull_request_latest,
588 578 prev_pull_request_at_ver,
589 579 prev_pull_request_display_obj,
590 580 prev_at_version) = self._get_pr_version(
591 581 pull_request_id, version=from_version)
592 582
593 583 c.from_version = prev_at_version
594 584 c.from_version_num = (prev_at_version
595 585 if prev_at_version and prev_at_version != 'latest'
596 586 else None)
597 587 c.from_version_pos = ChangesetComment.get_index_from_version(
598 588 c.from_version_num, versions)
599 589
600 590 # define if we're in COMPARE mode or VIEW at version mode
601 591 compare = at_version != prev_at_version
602 592
603 593 # pull_requests repo_name we opened it against
604 594 # ie. target_repo must match
605 595 if repo_name != pull_request_at_ver.target_repo.repo_name:
606 596 raise HTTPNotFound
607 597
608 598 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
609 599 pull_request_at_ver)
610 600
611 601 c.pull_request = pull_request_display_obj
612 602 c.pull_request_latest = pull_request_latest
613 603
614 604 if compare or (at_version and not at_version == 'latest'):
615 605 c.allowed_to_change_status = False
616 606 c.allowed_to_update = False
617 607 c.allowed_to_merge = False
618 608 c.allowed_to_delete = False
619 609 c.allowed_to_comment = False
620 610 c.allowed_to_close = False
621 611 else:
622 612 can_change_status = PullRequestModel().check_user_change_status(
623 613 pull_request_at_ver, c.rhodecode_user)
624 614 c.allowed_to_change_status = can_change_status and not pr_closed
625 615
626 616 c.allowed_to_update = PullRequestModel().check_user_update(
627 617 pull_request_latest, c.rhodecode_user) and not pr_closed
628 618 c.allowed_to_merge = PullRequestModel().check_user_merge(
629 619 pull_request_latest, c.rhodecode_user) and not pr_closed
630 620 c.allowed_to_delete = PullRequestModel().check_user_delete(
631 621 pull_request_latest, c.rhodecode_user) and not pr_closed
632 622 c.allowed_to_comment = not pr_closed
633 623 c.allowed_to_close = c.allowed_to_merge and not pr_closed
634 624
635 625 c.forbid_adding_reviewers = False
636 626 c.forbid_author_to_review = False
637 627 c.forbid_commit_author_to_review = False
638 628
639 629 if pull_request_latest.reviewer_data and \
640 630 'rules' in pull_request_latest.reviewer_data:
641 631 rules = pull_request_latest.reviewer_data['rules'] or {}
642 632 try:
643 633 c.forbid_adding_reviewers = rules.get(
644 634 'forbid_adding_reviewers')
645 635 c.forbid_author_to_review = rules.get(
646 636 'forbid_author_to_review')
647 637 c.forbid_commit_author_to_review = rules.get(
648 638 'forbid_commit_author_to_review')
649 639 except Exception:
650 640 pass
651 641
652 642 # check merge capabilities
653 643 _merge_check = MergeCheck.validate(
654 644 pull_request_latest, user=c.rhodecode_user)
655 645 c.pr_merge_errors = _merge_check.error_details
656 646 c.pr_merge_possible = not _merge_check.failed
657 647 c.pr_merge_message = _merge_check.merge_msg
658 648
659 649 c.pull_request_review_status = _merge_check.review_status
660 650 if merge_checks:
661 651 return render('/pullrequests/pullrequest_merge_checks.mako')
662 652
663 653 comments_model = CommentsModel()
664 654
665 655 # reviewers and statuses
666 656 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
667 657 allowed_reviewers = [x[0].user_id for x in c.pull_request_reviewers]
668 658
669 659 # GENERAL COMMENTS with versions #
670 660 q = comments_model._all_general_comments_of_pull_request(pull_request_latest)
671 661 q = q.order_by(ChangesetComment.comment_id.asc())
672 662 general_comments = q
673 663
674 664 # pick comments we want to render at current version
675 665 c.comment_versions = comments_model.aggregate_comments(
676 666 general_comments, versions, c.at_version_num)
677 667 c.comments = c.comment_versions[c.at_version_num]['until']
678 668
679 669 # INLINE COMMENTS with versions #
680 670 q = comments_model._all_inline_comments_of_pull_request(pull_request_latest)
681 671 q = q.order_by(ChangesetComment.comment_id.asc())
682 672 inline_comments = q
683 673
684 674 c.inline_versions = comments_model.aggregate_comments(
685 675 inline_comments, versions, c.at_version_num, inline=True)
686 676
687 677 # inject latest version
688 678 latest_ver = PullRequest.get_pr_display_object(
689 679 pull_request_latest, pull_request_latest)
690 680
691 681 c.versions = versions + [latest_ver]
692 682
693 683 # if we use version, then do not show later comments
694 684 # than current version
695 685 display_inline_comments = collections.defaultdict(
696 686 lambda: collections.defaultdict(list))
697 687 for co in inline_comments:
698 688 if c.at_version_num:
699 689 # pick comments that are at least UPTO given version, so we
700 690 # don't render comments for higher version
701 691 should_render = co.pull_request_version_id and \
702 692 co.pull_request_version_id <= c.at_version_num
703 693 else:
704 694 # showing all, for 'latest'
705 695 should_render = True
706 696
707 697 if should_render:
708 698 display_inline_comments[co.f_path][co.line_no].append(co)
709 699
710 700 # load diff data into template context, if we use compare mode then
711 701 # diff is calculated based on changes between versions of PR
712 702
713 703 source_repo = pull_request_at_ver.source_repo
714 704 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
715 705
716 706 target_repo = pull_request_at_ver.target_repo
717 707 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
718 708
719 709 if compare:
720 710 # in compare switch the diff base to latest commit from prev version
721 711 target_ref_id = prev_pull_request_display_obj.revisions[0]
722 712
723 713 # despite opening commits for bookmarks/branches/tags, we always
724 714 # convert this to rev to prevent changes after bookmark or branch change
725 715 c.source_ref_type = 'rev'
726 716 c.source_ref = source_ref_id
727 717
728 718 c.target_ref_type = 'rev'
729 719 c.target_ref = target_ref_id
730 720
731 721 c.source_repo = source_repo
732 722 c.target_repo = target_repo
733 723
734 724 # diff_limit is the old behavior, will cut off the whole diff
735 725 # if the limit is applied otherwise will just hide the
736 726 # big files from the front-end
737 727 diff_limit = self.cut_off_limit_diff
738 728 file_limit = self.cut_off_limit_file
739 729
740 730 c.commit_ranges = []
741 731 source_commit = EmptyCommit()
742 732 target_commit = EmptyCommit()
743 733 c.missing_requirements = False
744 734
745 735 source_scm = source_repo.scm_instance()
746 736 target_scm = target_repo.scm_instance()
747 737
748 738 # try first shadow repo, fallback to regular repo
749 739 try:
750 740 commits_source_repo = pull_request_latest.get_shadow_repo()
751 741 except Exception:
752 742 log.debug('Failed to get shadow repo', exc_info=True)
753 743 commits_source_repo = source_scm
754 744
755 745 c.commits_source_repo = commits_source_repo
756 746 commit_cache = {}
757 747 try:
758 748 pre_load = ["author", "branch", "date", "message"]
759 749 show_revs = pull_request_at_ver.revisions
760 750 for rev in show_revs:
761 751 comm = commits_source_repo.get_commit(
762 752 commit_id=rev, pre_load=pre_load)
763 753 c.commit_ranges.append(comm)
764 754 commit_cache[comm.raw_id] = comm
765 755
766 756 # Order here matters, we first need to get target, and then
767 757 # the source
768 758 target_commit = commits_source_repo.get_commit(
769 759 commit_id=safe_str(target_ref_id))
770 760
771 761 source_commit = commits_source_repo.get_commit(
772 762 commit_id=safe_str(source_ref_id))
773 763
774 764 except CommitDoesNotExistError:
775 765 log.warning(
776 766 'Failed to get commit from `{}` repo'.format(
777 767 commits_source_repo), exc_info=True)
778 768 except RepositoryRequirementError:
779 769 log.warning(
780 770 'Failed to get all required data from repo', exc_info=True)
781 771 c.missing_requirements = True
782 772
783 773 c.ancestor = None # set it to None, to hide it from PR view
784 774
785 775 try:
786 776 ancestor_id = source_scm.get_common_ancestor(
787 777 source_commit.raw_id, target_commit.raw_id, target_scm)
788 778 c.ancestor_commit = source_scm.get_commit(ancestor_id)
789 779 except Exception:
790 780 c.ancestor_commit = None
791 781
792 782 c.statuses = source_repo.statuses(
793 783 [x.raw_id for x in c.commit_ranges])
794 784
795 785 # auto collapse if we have more than limit
796 786 collapse_limit = diffs.DiffProcessor._collapse_commits_over
797 787 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
798 788 c.compare_mode = compare
799 789
800 790 c.missing_commits = False
801 791 if (c.missing_requirements or isinstance(source_commit, EmptyCommit)
802 792 or source_commit == target_commit):
803 793
804 794 c.missing_commits = True
805 795 else:
806 796
807 797 c.diffset = self._get_diffset(
808 798 commits_source_repo, source_ref_id, target_ref_id,
809 799 target_commit, source_commit,
810 800 diff_limit, file_limit, display_inline_comments)
811 801
812 802 c.limited_diff = c.diffset.limited_diff
813 803
814 804 # calculate removed files that are bound to comments
815 805 comment_deleted_files = [
816 806 fname for fname in display_inline_comments
817 807 if fname not in c.diffset.file_stats]
818 808
819 809 c.deleted_files_comments = collections.defaultdict(dict)
820 810 for fname, per_line_comments in display_inline_comments.items():
821 811 if fname in comment_deleted_files:
822 812 c.deleted_files_comments[fname]['stats'] = 0
823 813 c.deleted_files_comments[fname]['comments'] = list()
824 814 for lno, comments in per_line_comments.items():
825 815 c.deleted_files_comments[fname]['comments'].extend(
826 816 comments)
827 817
828 818 # this is a hack to properly display links, when creating PR, the
829 819 # compare view and others uses different notation, and
830 820 # compare_commits.mako renders links based on the target_repo.
831 821 # We need to swap that here to generate it properly on the html side
832 822 c.target_repo = c.source_repo
833 823
834 824 c.commit_statuses = ChangesetStatus.STATUSES
835 825
836 826 c.show_version_changes = not pr_closed
837 827 if c.show_version_changes:
838 828 cur_obj = pull_request_at_ver
839 829 prev_obj = prev_pull_request_at_ver
840 830
841 831 old_commit_ids = prev_obj.revisions
842 832 new_commit_ids = cur_obj.revisions
843 833 commit_changes = PullRequestModel()._calculate_commit_id_changes(
844 834 old_commit_ids, new_commit_ids)
845 835 c.commit_changes_summary = commit_changes
846 836
847 837 # calculate the diff for commits between versions
848 838 c.commit_changes = []
849 839 mark = lambda cs, fw: list(
850 840 h.itertools.izip_longest([], cs, fillvalue=fw))
851 841 for c_type, raw_id in mark(commit_changes.added, 'a') \
852 842 + mark(commit_changes.removed, 'r') \
853 843 + mark(commit_changes.common, 'c'):
854 844
855 845 if raw_id in commit_cache:
856 846 commit = commit_cache[raw_id]
857 847 else:
858 848 try:
859 849 commit = commits_source_repo.get_commit(raw_id)
860 850 except CommitDoesNotExistError:
861 851 # in case we fail extracting still use "dummy" commit
862 852 # for display in commit diff
863 853 commit = h.AttributeDict(
864 854 {'raw_id': raw_id,
865 855 'message': 'EMPTY or MISSING COMMIT'})
866 856 c.commit_changes.append([c_type, commit])
867 857
868 858 # current user review statuses for each version
869 859 c.review_versions = {}
870 860 if c.rhodecode_user.user_id in allowed_reviewers:
871 861 for co in general_comments:
872 862 if co.author.user_id == c.rhodecode_user.user_id:
873 863 # each comment has a status change
874 864 status = co.status_change
875 865 if status:
876 866 _ver_pr = status[0].comment.pull_request_version_id
877 867 c.review_versions[_ver_pr] = status[0]
878 868
879 869 return render('/pullrequests/pullrequest_show.mako')
880 870
881 871 @LoginRequired()
882 872 @NotAnonymous()
883 873 @HasRepoPermissionAnyDecorator(
884 874 'repository.read', 'repository.write', 'repository.admin')
885 875 @auth.CSRFRequired()
886 876 @jsonify
887 877 def comment(self, repo_name, pull_request_id):
888 878 pull_request_id = safe_int(pull_request_id)
889 879 pull_request = PullRequest.get_or_404(pull_request_id)
890 880 if pull_request.is_closed():
881 log.debug('comment: forbidden because pull request is closed')
891 882 raise HTTPForbidden()
892 883
893 884 status = request.POST.get('changeset_status', None)
894 885 text = request.POST.get('text')
895 886 comment_type = request.POST.get('comment_type')
896 887 resolves_comment_id = request.POST.get('resolves_comment_id', None)
897 888 close_pull_request = request.POST.get('close_pull_request')
898 889
899 close_pr = False
900 # only owner or admin or person with write permissions
901 allowed_to_close = PullRequestModel().check_user_update(
902 pull_request, c.rhodecode_user)
890 # the logic here should work like following, if we submit close
891 # pr comment, use `close_pull_request_with_comment` function
892 # else handle regular comment logic
893 user = c.rhodecode_user
894 repo = c.rhodecode_db_repo
903 895
904 if close_pull_request and allowed_to_close:
905 close_pr = True
906 pull_request_review_status = pull_request.calculated_review_status()
907 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
908 # approved only if we have voting consent
909 status = ChangesetStatus.STATUS_APPROVED
910 else:
911 status = ChangesetStatus.STATUS_REJECTED
896 if close_pull_request:
897 # only owner or admin or person with write permissions
898 allowed_to_close = PullRequestModel().check_user_update(
899 pull_request, c.rhodecode_user)
900 if not allowed_to_close:
901 log.debug('comment: forbidden because not allowed to close '
902 'pull request %s', pull_request_id)
903 raise HTTPForbidden()
904 comment, status = PullRequestModel().close_pull_request_with_comment(
905 pull_request, user, repo, message=text)
906 Session().flush()
907 events.trigger(
908 events.PullRequestCommentEvent(pull_request, comment))
912 909
913 allowed_to_change_status = PullRequestModel().check_user_change_status(
914 pull_request, c.rhodecode_user)
910 else:
911 # regular comment case, could be inline, or one with status.
912 # for that one we check also permissions
913
914 allowed_to_change_status = PullRequestModel().check_user_change_status(
915 pull_request, c.rhodecode_user)
916
917 if status and allowed_to_change_status:
918 message = (_('Status change %(transition_icon)s %(status)s')
919 % {'transition_icon': '>',
920 'status': ChangesetStatus.get_status_lbl(status)})
921 text = text or message
915 922
916 if status and allowed_to_change_status:
917 message = (_('Status change %(transition_icon)s %(status)s')
918 % {'transition_icon': '>',
919 'status': ChangesetStatus.get_status_lbl(status)})
920 if close_pr:
921 message = _('Closing with') + ' ' + message
922 text = text or message
923 comm = CommentsModel().create(
924 text=text,
925 repo=c.rhodecode_db_repo.repo_id,
926 user=c.rhodecode_user.user_id,
927 pull_request=pull_request_id,
928 f_path=request.POST.get('f_path'),
929 line_no=request.POST.get('line'),
930 status_change=(ChangesetStatus.get_status_lbl(status)
931 if status and allowed_to_change_status else None),
932 status_change_type=(status
933 if status and allowed_to_change_status else None),
934 closing_pr=close_pr,
935 comment_type=comment_type,
936 resolves_comment_id=resolves_comment_id
937 )
923 comment = CommentsModel().create(
924 text=text,
925 repo=c.rhodecode_db_repo.repo_id,
926 user=c.rhodecode_user.user_id,
927 pull_request=pull_request_id,
928 f_path=request.POST.get('f_path'),
929 line_no=request.POST.get('line'),
930 status_change=(ChangesetStatus.get_status_lbl(status)
931 if status and allowed_to_change_status else None),
932 status_change_type=(status
933 if status and allowed_to_change_status else None),
934 comment_type=comment_type,
935 resolves_comment_id=resolves_comment_id
936 )
937
938 if allowed_to_change_status:
939 # calculate old status before we change it
940 old_calculated_status = pull_request.calculated_review_status()
938 941
939 if allowed_to_change_status:
940 old_calculated_status = pull_request.calculated_review_status()
941 # get status if set !
942 if status:
943 ChangesetStatusModel().set_status(
944 c.rhodecode_db_repo.repo_id,
945 status,
946 c.rhodecode_user.user_id,
947 comm,
948 pull_request=pull_request_id
949 )
942 # get status if set !
943 if status:
944 ChangesetStatusModel().set_status(
945 c.rhodecode_db_repo.repo_id,
946 status,
947 c.rhodecode_user.user_id,
948 comment,
949 pull_request=pull_request_id
950 )
950 951
951 Session().flush()
952 events.trigger(events.PullRequestCommentEvent(pull_request, comm))
953 # we now calculate the status of pull request, and based on that
954 # calculation we set the commits status
955 calculated_status = pull_request.calculated_review_status()
956 if old_calculated_status != calculated_status:
957 PullRequestModel()._trigger_pull_request_hook(
958 pull_request, c.rhodecode_user, 'review_status_change')
959
960 calculated_status_lbl = ChangesetStatus.get_status_lbl(
961 calculated_status)
952 Session().flush()
953 events.trigger(
954 events.PullRequestCommentEvent(pull_request, comment))
962 955
963 if close_pr:
964 status_completed = (
965 calculated_status in [ChangesetStatus.STATUS_APPROVED,
966 ChangesetStatus.STATUS_REJECTED])
967 if close_pull_request or status_completed:
968 PullRequestModel().close_pull_request(
969 pull_request_id, c.rhodecode_user)
970 else:
971 h.flash(_('Closing pull request on other statuses than '
972 'rejected or approved is forbidden. '
973 'Calculated status from all reviewers '
974 'is currently: %s') % calculated_status_lbl,
975 category='warning')
956 # we now calculate the status of pull request, and based on that
957 # calculation we set the commits status
958 calculated_status = pull_request.calculated_review_status()
959 if old_calculated_status != calculated_status:
960 PullRequestModel()._trigger_pull_request_hook(
961 pull_request, c.rhodecode_user, 'review_status_change')
976 962
977 963 Session().commit()
978 964
979 965 if not request.is_xhr:
980 966 return redirect(h.url('pullrequest_show', repo_name=repo_name,
981 967 pull_request_id=pull_request_id))
982 968
983 969 data = {
984 970 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
985 971 }
986 if comm:
987 c.co = comm
988 c.inline_comment = True if comm.line_no else False
989 data.update(comm.get_dict())
990 data.update({'rendered_text':
991 render('changeset/changeset_comment_block.mako')})
972 if comment:
973 c.co = comment
974 rendered_comment = render('changeset/changeset_comment_block.mako')
975 data.update(comment.get_dict())
976 data.update({'rendered_text': rendered_comment})
992 977
993 978 return data
994 979
995 980 @LoginRequired()
996 981 @NotAnonymous()
997 982 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
998 983 'repository.admin')
999 984 @auth.CSRFRequired()
1000 985 @jsonify
1001 986 def delete_comment(self, repo_name, comment_id):
1002 987 return self._delete_comment(comment_id)
1003 988
1004 989 def _delete_comment(self, comment_id):
1005 990 comment_id = safe_int(comment_id)
1006 991 co = ChangesetComment.get_or_404(comment_id)
1007 992 if co.pull_request.is_closed():
1008 993 # don't allow deleting comments on closed pull request
1009 994 raise HTTPForbidden()
1010 995
1011 996 is_owner = co.author.user_id == c.rhodecode_user.user_id
1012 997 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
1013 998 if h.HasPermissionAny('hg.admin')() or is_repo_admin or is_owner:
1014 999 old_calculated_status = co.pull_request.calculated_review_status()
1015 1000 CommentsModel().delete(comment=co)
1016 1001 Session().commit()
1017 1002 calculated_status = co.pull_request.calculated_review_status()
1018 1003 if old_calculated_status != calculated_status:
1019 1004 PullRequestModel()._trigger_pull_request_hook(
1020 1005 co.pull_request, c.rhodecode_user, 'review_status_change')
1021 1006 return True
1022 1007 else:
1023 1008 raise HTTPForbidden()
@@ -1,4026 +1,4030 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 Database Models for RhodeCode Enterprise
23 23 """
24 24
25 25 import re
26 26 import os
27 27 import time
28 28 import hashlib
29 29 import logging
30 30 import datetime
31 31 import warnings
32 32 import ipaddress
33 33 import functools
34 34 import traceback
35 35 import collections
36 36
37 37
38 38 from sqlalchemy import *
39 39 from sqlalchemy.ext.declarative import declared_attr
40 40 from sqlalchemy.ext.hybrid import hybrid_property
41 41 from sqlalchemy.orm import (
42 42 relationship, joinedload, class_mapper, validates, aliased)
43 43 from sqlalchemy.sql.expression import true
44 44 from beaker.cache import cache_region
45 45 from zope.cachedescriptors.property import Lazy as LazyProperty
46 46
47 47 from pylons.i18n.translation import lazy_ugettext as _
48 48 from pyramid.threadlocal import get_current_request
49 49
50 50 from rhodecode.lib.vcs import get_vcs_instance
51 51 from rhodecode.lib.vcs.backends.base import EmptyCommit, Reference
52 52 from rhodecode.lib.utils2 import (
53 53 str2bool, safe_str, get_commit_safe, safe_unicode, md5_safe,
54 54 time_to_datetime, aslist, Optional, safe_int, get_clone_url, AttributeDict,
55 55 glob2re, StrictAttributeDict, cleaned_uri)
56 56 from rhodecode.lib.jsonalchemy import MutationObj, MutationList, JsonType
57 57 from rhodecode.lib.ext_json import json
58 58 from rhodecode.lib.caching_query import FromCache
59 59 from rhodecode.lib.encrypt import AESCipher
60 60
61 61 from rhodecode.model.meta import Base, Session
62 62
63 63 URL_SEP = '/'
64 64 log = logging.getLogger(__name__)
65 65
66 66 # =============================================================================
67 67 # BASE CLASSES
68 68 # =============================================================================
69 69
70 70 # this is propagated from .ini file rhodecode.encrypted_values.secret or
71 71 # beaker.session.secret if first is not set.
72 72 # and initialized at environment.py
73 73 ENCRYPTION_KEY = None
74 74
75 75 # used to sort permissions by types, '#' used here is not allowed to be in
76 76 # usernames, and it's very early in sorted string.printable table.
77 77 PERMISSION_TYPE_SORT = {
78 78 'admin': '####',
79 79 'write': '###',
80 80 'read': '##',
81 81 'none': '#',
82 82 }
83 83
84 84
85 85 def display_sort(obj):
86 86 """
87 87 Sort function used to sort permissions in .permissions() function of
88 88 Repository, RepoGroup, UserGroup. Also it put the default user in front
89 89 of all other resources
90 90 """
91 91
92 92 if obj.username == User.DEFAULT_USER:
93 93 return '#####'
94 94 prefix = PERMISSION_TYPE_SORT.get(obj.permission.split('.')[-1], '')
95 95 return prefix + obj.username
96 96
97 97
98 98 def _hash_key(k):
99 99 return md5_safe(k)
100 100
101 101
102 102 class EncryptedTextValue(TypeDecorator):
103 103 """
104 104 Special column for encrypted long text data, use like::
105 105
106 106 value = Column("encrypted_value", EncryptedValue(), nullable=False)
107 107
108 108 This column is intelligent so if value is in unencrypted form it return
109 109 unencrypted form, but on save it always encrypts
110 110 """
111 111 impl = Text
112 112
113 113 def process_bind_param(self, value, dialect):
114 114 if not value:
115 115 return value
116 116 if value.startswith('enc$aes$') or value.startswith('enc$aes_hmac$'):
117 117 # protect against double encrypting if someone manually starts
118 118 # doing
119 119 raise ValueError('value needs to be in unencrypted format, ie. '
120 120 'not starting with enc$aes')
121 121 return 'enc$aes_hmac$%s' % AESCipher(
122 122 ENCRYPTION_KEY, hmac=True).encrypt(value)
123 123
124 124 def process_result_value(self, value, dialect):
125 125 import rhodecode
126 126
127 127 if not value:
128 128 return value
129 129
130 130 parts = value.split('$', 3)
131 131 if not len(parts) == 3:
132 132 # probably not encrypted values
133 133 return value
134 134 else:
135 135 if parts[0] != 'enc':
136 136 # parts ok but without our header ?
137 137 return value
138 138 enc_strict_mode = str2bool(rhodecode.CONFIG.get(
139 139 'rhodecode.encrypted_values.strict') or True)
140 140 # at that stage we know it's our encryption
141 141 if parts[1] == 'aes':
142 142 decrypted_data = AESCipher(ENCRYPTION_KEY).decrypt(parts[2])
143 143 elif parts[1] == 'aes_hmac':
144 144 decrypted_data = AESCipher(
145 145 ENCRYPTION_KEY, hmac=True,
146 146 strict_verification=enc_strict_mode).decrypt(parts[2])
147 147 else:
148 148 raise ValueError(
149 149 'Encryption type part is wrong, must be `aes` '
150 150 'or `aes_hmac`, got `%s` instead' % (parts[1]))
151 151 return decrypted_data
152 152
153 153
154 154 class BaseModel(object):
155 155 """
156 156 Base Model for all classes
157 157 """
158 158
159 159 @classmethod
160 160 def _get_keys(cls):
161 161 """return column names for this model """
162 162 return class_mapper(cls).c.keys()
163 163
164 164 def get_dict(self):
165 165 """
166 166 return dict with keys and values corresponding
167 167 to this model data """
168 168
169 169 d = {}
170 170 for k in self._get_keys():
171 171 d[k] = getattr(self, k)
172 172
173 173 # also use __json__() if present to get additional fields
174 174 _json_attr = getattr(self, '__json__', None)
175 175 if _json_attr:
176 176 # update with attributes from __json__
177 177 if callable(_json_attr):
178 178 _json_attr = _json_attr()
179 179 for k, val in _json_attr.iteritems():
180 180 d[k] = val
181 181 return d
182 182
183 183 def get_appstruct(self):
184 184 """return list with keys and values tuples corresponding
185 185 to this model data """
186 186
187 187 l = []
188 188 for k in self._get_keys():
189 189 l.append((k, getattr(self, k),))
190 190 return l
191 191
192 192 def populate_obj(self, populate_dict):
193 193 """populate model with data from given populate_dict"""
194 194
195 195 for k in self._get_keys():
196 196 if k in populate_dict:
197 197 setattr(self, k, populate_dict[k])
198 198
199 199 @classmethod
200 200 def query(cls):
201 201 return Session().query(cls)
202 202
203 203 @classmethod
204 204 def get(cls, id_):
205 205 if id_:
206 206 return cls.query().get(id_)
207 207
208 208 @classmethod
209 209 def get_or_404(cls, id_, pyramid_exc=False):
210 210 if pyramid_exc:
211 211 # NOTE(marcink): backward compat, once migration to pyramid
212 212 # this should only use pyramid exceptions
213 213 from pyramid.httpexceptions import HTTPNotFound
214 214 else:
215 215 from webob.exc import HTTPNotFound
216 216
217 217 try:
218 218 id_ = int(id_)
219 219 except (TypeError, ValueError):
220 220 raise HTTPNotFound
221 221
222 222 res = cls.query().get(id_)
223 223 if not res:
224 224 raise HTTPNotFound
225 225 return res
226 226
227 227 @classmethod
228 228 def getAll(cls):
229 229 # deprecated and left for backward compatibility
230 230 return cls.get_all()
231 231
232 232 @classmethod
233 233 def get_all(cls):
234 234 return cls.query().all()
235 235
236 236 @classmethod
237 237 def delete(cls, id_):
238 238 obj = cls.query().get(id_)
239 239 Session().delete(obj)
240 240
241 241 @classmethod
242 242 def identity_cache(cls, session, attr_name, value):
243 243 exist_in_session = []
244 244 for (item_cls, pkey), instance in session.identity_map.items():
245 245 if cls == item_cls and getattr(instance, attr_name) == value:
246 246 exist_in_session.append(instance)
247 247 if exist_in_session:
248 248 if len(exist_in_session) == 1:
249 249 return exist_in_session[0]
250 250 log.exception(
251 251 'multiple objects with attr %s and '
252 252 'value %s found with same name: %r',
253 253 attr_name, value, exist_in_session)
254 254
255 255 def __repr__(self):
256 256 if hasattr(self, '__unicode__'):
257 257 # python repr needs to return str
258 258 try:
259 259 return safe_str(self.__unicode__())
260 260 except UnicodeDecodeError:
261 261 pass
262 262 return '<DB:%s>' % (self.__class__.__name__)
263 263
264 264
265 265 class RhodeCodeSetting(Base, BaseModel):
266 266 __tablename__ = 'rhodecode_settings'
267 267 __table_args__ = (
268 268 UniqueConstraint('app_settings_name'),
269 269 {'extend_existing': True, 'mysql_engine': 'InnoDB',
270 270 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
271 271 )
272 272
273 273 SETTINGS_TYPES = {
274 274 'str': safe_str,
275 275 'int': safe_int,
276 276 'unicode': safe_unicode,
277 277 'bool': str2bool,
278 278 'list': functools.partial(aslist, sep=',')
279 279 }
280 280 DEFAULT_UPDATE_URL = 'https://rhodecode.com/api/v1/info/versions'
281 281 GLOBAL_CONF_KEY = 'app_settings'
282 282
283 283 app_settings_id = Column("app_settings_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
284 284 app_settings_name = Column("app_settings_name", String(255), nullable=True, unique=None, default=None)
285 285 _app_settings_value = Column("app_settings_value", String(4096), nullable=True, unique=None, default=None)
286 286 _app_settings_type = Column("app_settings_type", String(255), nullable=True, unique=None, default=None)
287 287
288 288 def __init__(self, key='', val='', type='unicode'):
289 289 self.app_settings_name = key
290 290 self.app_settings_type = type
291 291 self.app_settings_value = val
292 292
293 293 @validates('_app_settings_value')
294 294 def validate_settings_value(self, key, val):
295 295 assert type(val) == unicode
296 296 return val
297 297
298 298 @hybrid_property
299 299 def app_settings_value(self):
300 300 v = self._app_settings_value
301 301 _type = self.app_settings_type
302 302 if _type:
303 303 _type = self.app_settings_type.split('.')[0]
304 304 # decode the encrypted value
305 305 if 'encrypted' in self.app_settings_type:
306 306 cipher = EncryptedTextValue()
307 307 v = safe_unicode(cipher.process_result_value(v, None))
308 308
309 309 converter = self.SETTINGS_TYPES.get(_type) or \
310 310 self.SETTINGS_TYPES['unicode']
311 311 return converter(v)
312 312
313 313 @app_settings_value.setter
314 314 def app_settings_value(self, val):
315 315 """
316 316 Setter that will always make sure we use unicode in app_settings_value
317 317
318 318 :param val:
319 319 """
320 320 val = safe_unicode(val)
321 321 # encode the encrypted value
322 322 if 'encrypted' in self.app_settings_type:
323 323 cipher = EncryptedTextValue()
324 324 val = safe_unicode(cipher.process_bind_param(val, None))
325 325 self._app_settings_value = val
326 326
327 327 @hybrid_property
328 328 def app_settings_type(self):
329 329 return self._app_settings_type
330 330
331 331 @app_settings_type.setter
332 332 def app_settings_type(self, val):
333 333 if val.split('.')[0] not in self.SETTINGS_TYPES:
334 334 raise Exception('type must be one of %s got %s'
335 335 % (self.SETTINGS_TYPES.keys(), val))
336 336 self._app_settings_type = val
337 337
338 338 def __unicode__(self):
339 339 return u"<%s('%s:%s[%s]')>" % (
340 340 self.__class__.__name__,
341 341 self.app_settings_name, self.app_settings_value,
342 342 self.app_settings_type
343 343 )
344 344
345 345
346 346 class RhodeCodeUi(Base, BaseModel):
347 347 __tablename__ = 'rhodecode_ui'
348 348 __table_args__ = (
349 349 UniqueConstraint('ui_key'),
350 350 {'extend_existing': True, 'mysql_engine': 'InnoDB',
351 351 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
352 352 )
353 353
354 354 HOOK_REPO_SIZE = 'changegroup.repo_size'
355 355 # HG
356 356 HOOK_PRE_PULL = 'preoutgoing.pre_pull'
357 357 HOOK_PULL = 'outgoing.pull_logger'
358 358 HOOK_PRE_PUSH = 'prechangegroup.pre_push'
359 359 HOOK_PRETX_PUSH = 'pretxnchangegroup.pre_push'
360 360 HOOK_PUSH = 'changegroup.push_logger'
361 361 HOOK_PUSH_KEY = 'pushkey.key_push'
362 362
363 363 # TODO: johbo: Unify way how hooks are configured for git and hg,
364 364 # git part is currently hardcoded.
365 365
366 366 # SVN PATTERNS
367 367 SVN_BRANCH_ID = 'vcs_svn_branch'
368 368 SVN_TAG_ID = 'vcs_svn_tag'
369 369
370 370 ui_id = Column(
371 371 "ui_id", Integer(), nullable=False, unique=True, default=None,
372 372 primary_key=True)
373 373 ui_section = Column(
374 374 "ui_section", String(255), nullable=True, unique=None, default=None)
375 375 ui_key = Column(
376 376 "ui_key", String(255), nullable=True, unique=None, default=None)
377 377 ui_value = Column(
378 378 "ui_value", String(255), nullable=True, unique=None, default=None)
379 379 ui_active = Column(
380 380 "ui_active", Boolean(), nullable=True, unique=None, default=True)
381 381
382 382 def __repr__(self):
383 383 return '<%s[%s]%s=>%s]>' % (self.__class__.__name__, self.ui_section,
384 384 self.ui_key, self.ui_value)
385 385
386 386
387 387 class RepoRhodeCodeSetting(Base, BaseModel):
388 388 __tablename__ = 'repo_rhodecode_settings'
389 389 __table_args__ = (
390 390 UniqueConstraint(
391 391 'app_settings_name', 'repository_id',
392 392 name='uq_repo_rhodecode_setting_name_repo_id'),
393 393 {'extend_existing': True, 'mysql_engine': 'InnoDB',
394 394 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
395 395 )
396 396
397 397 repository_id = Column(
398 398 "repository_id", Integer(), ForeignKey('repositories.repo_id'),
399 399 nullable=False)
400 400 app_settings_id = Column(
401 401 "app_settings_id", Integer(), nullable=False, unique=True,
402 402 default=None, primary_key=True)
403 403 app_settings_name = Column(
404 404 "app_settings_name", String(255), nullable=True, unique=None,
405 405 default=None)
406 406 _app_settings_value = Column(
407 407 "app_settings_value", String(4096), nullable=True, unique=None,
408 408 default=None)
409 409 _app_settings_type = Column(
410 410 "app_settings_type", String(255), nullable=True, unique=None,
411 411 default=None)
412 412
413 413 repository = relationship('Repository')
414 414
415 415 def __init__(self, repository_id, key='', val='', type='unicode'):
416 416 self.repository_id = repository_id
417 417 self.app_settings_name = key
418 418 self.app_settings_type = type
419 419 self.app_settings_value = val
420 420
421 421 @validates('_app_settings_value')
422 422 def validate_settings_value(self, key, val):
423 423 assert type(val) == unicode
424 424 return val
425 425
426 426 @hybrid_property
427 427 def app_settings_value(self):
428 428 v = self._app_settings_value
429 429 type_ = self.app_settings_type
430 430 SETTINGS_TYPES = RhodeCodeSetting.SETTINGS_TYPES
431 431 converter = SETTINGS_TYPES.get(type_) or SETTINGS_TYPES['unicode']
432 432 return converter(v)
433 433
434 434 @app_settings_value.setter
435 435 def app_settings_value(self, val):
436 436 """
437 437 Setter that will always make sure we use unicode in app_settings_value
438 438
439 439 :param val:
440 440 """
441 441 self._app_settings_value = safe_unicode(val)
442 442
443 443 @hybrid_property
444 444 def app_settings_type(self):
445 445 return self._app_settings_type
446 446
447 447 @app_settings_type.setter
448 448 def app_settings_type(self, val):
449 449 SETTINGS_TYPES = RhodeCodeSetting.SETTINGS_TYPES
450 450 if val not in SETTINGS_TYPES:
451 451 raise Exception('type must be one of %s got %s'
452 452 % (SETTINGS_TYPES.keys(), val))
453 453 self._app_settings_type = val
454 454
455 455 def __unicode__(self):
456 456 return u"<%s('%s:%s:%s[%s]')>" % (
457 457 self.__class__.__name__, self.repository.repo_name,
458 458 self.app_settings_name, self.app_settings_value,
459 459 self.app_settings_type
460 460 )
461 461
462 462
463 463 class RepoRhodeCodeUi(Base, BaseModel):
464 464 __tablename__ = 'repo_rhodecode_ui'
465 465 __table_args__ = (
466 466 UniqueConstraint(
467 467 'repository_id', 'ui_section', 'ui_key',
468 468 name='uq_repo_rhodecode_ui_repository_id_section_key'),
469 469 {'extend_existing': True, 'mysql_engine': 'InnoDB',
470 470 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
471 471 )
472 472
473 473 repository_id = Column(
474 474 "repository_id", Integer(), ForeignKey('repositories.repo_id'),
475 475 nullable=False)
476 476 ui_id = Column(
477 477 "ui_id", Integer(), nullable=False, unique=True, default=None,
478 478 primary_key=True)
479 479 ui_section = Column(
480 480 "ui_section", String(255), nullable=True, unique=None, default=None)
481 481 ui_key = Column(
482 482 "ui_key", String(255), nullable=True, unique=None, default=None)
483 483 ui_value = Column(
484 484 "ui_value", String(255), nullable=True, unique=None, default=None)
485 485 ui_active = Column(
486 486 "ui_active", Boolean(), nullable=True, unique=None, default=True)
487 487
488 488 repository = relationship('Repository')
489 489
490 490 def __repr__(self):
491 491 return '<%s[%s:%s]%s=>%s]>' % (
492 492 self.__class__.__name__, self.repository.repo_name,
493 493 self.ui_section, self.ui_key, self.ui_value)
494 494
495 495
496 496 class User(Base, BaseModel):
497 497 __tablename__ = 'users'
498 498 __table_args__ = (
499 499 UniqueConstraint('username'), UniqueConstraint('email'),
500 500 Index('u_username_idx', 'username'),
501 501 Index('u_email_idx', 'email'),
502 502 {'extend_existing': True, 'mysql_engine': 'InnoDB',
503 503 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
504 504 )
505 505 DEFAULT_USER = 'default'
506 506 DEFAULT_USER_EMAIL = 'anonymous@rhodecode.org'
507 507 DEFAULT_GRAVATAR_URL = 'https://secure.gravatar.com/avatar/{md5email}?d=identicon&s={size}'
508 508
509 509 user_id = Column("user_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
510 510 username = Column("username", String(255), nullable=True, unique=None, default=None)
511 511 password = Column("password", String(255), nullable=True, unique=None, default=None)
512 512 active = Column("active", Boolean(), nullable=True, unique=None, default=True)
513 513 admin = Column("admin", Boolean(), nullable=True, unique=None, default=False)
514 514 name = Column("firstname", String(255), nullable=True, unique=None, default=None)
515 515 lastname = Column("lastname", String(255), nullable=True, unique=None, default=None)
516 516 _email = Column("email", String(255), nullable=True, unique=None, default=None)
517 517 last_login = Column("last_login", DateTime(timezone=False), nullable=True, unique=None, default=None)
518 518 last_activity = Column('last_activity', DateTime(timezone=False), nullable=True, unique=None, default=None)
519 519
520 520 extern_type = Column("extern_type", String(255), nullable=True, unique=None, default=None)
521 521 extern_name = Column("extern_name", String(255), nullable=True, unique=None, default=None)
522 522 _api_key = Column("api_key", String(255), nullable=True, unique=None, default=None)
523 523 inherit_default_permissions = Column("inherit_default_permissions", Boolean(), nullable=False, unique=None, default=True)
524 524 created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
525 525 _user_data = Column("user_data", LargeBinary(), nullable=True) # JSON data
526 526
527 527 user_log = relationship('UserLog')
528 528 user_perms = relationship('UserToPerm', primaryjoin="User.user_id==UserToPerm.user_id", cascade='all')
529 529
530 530 repositories = relationship('Repository')
531 531 repository_groups = relationship('RepoGroup')
532 532 user_groups = relationship('UserGroup')
533 533
534 534 user_followers = relationship('UserFollowing', primaryjoin='UserFollowing.follows_user_id==User.user_id', cascade='all')
535 535 followings = relationship('UserFollowing', primaryjoin='UserFollowing.user_id==User.user_id', cascade='all')
536 536
537 537 repo_to_perm = relationship('UserRepoToPerm', primaryjoin='UserRepoToPerm.user_id==User.user_id', cascade='all')
538 538 repo_group_to_perm = relationship('UserRepoGroupToPerm', primaryjoin='UserRepoGroupToPerm.user_id==User.user_id', cascade='all')
539 539 user_group_to_perm = relationship('UserUserGroupToPerm', primaryjoin='UserUserGroupToPerm.user_id==User.user_id', cascade='all')
540 540
541 541 group_member = relationship('UserGroupMember', cascade='all')
542 542
543 543 notifications = relationship('UserNotification', cascade='all')
544 544 # notifications assigned to this user
545 545 user_created_notifications = relationship('Notification', cascade='all')
546 546 # comments created by this user
547 547 user_comments = relationship('ChangesetComment', cascade='all')
548 548 # user profile extra info
549 549 user_emails = relationship('UserEmailMap', cascade='all')
550 550 user_ip_map = relationship('UserIpMap', cascade='all')
551 551 user_auth_tokens = relationship('UserApiKeys', cascade='all')
552 552 # gists
553 553 user_gists = relationship('Gist', cascade='all')
554 554 # user pull requests
555 555 user_pull_requests = relationship('PullRequest', cascade='all')
556 556 # external identities
557 557 extenal_identities = relationship(
558 558 'ExternalIdentity',
559 559 primaryjoin="User.user_id==ExternalIdentity.local_user_id",
560 560 cascade='all')
561 561
562 562 def __unicode__(self):
563 563 return u"<%s('id:%s:%s')>" % (self.__class__.__name__,
564 564 self.user_id, self.username)
565 565
566 566 @hybrid_property
567 567 def email(self):
568 568 return self._email
569 569
570 570 @email.setter
571 571 def email(self, val):
572 572 self._email = val.lower() if val else None
573 573
574 574 @hybrid_property
575 575 def api_key(self):
576 576 """
577 577 Fetch if exist an auth-token with role ALL connected to this user
578 578 """
579 579 user_auth_token = UserApiKeys.query()\
580 580 .filter(UserApiKeys.user_id == self.user_id)\
581 581 .filter(or_(UserApiKeys.expires == -1,
582 582 UserApiKeys.expires >= time.time()))\
583 583 .filter(UserApiKeys.role == UserApiKeys.ROLE_ALL).first()
584 584 if user_auth_token:
585 585 user_auth_token = user_auth_token.api_key
586 586
587 587 return user_auth_token
588 588
589 589 @api_key.setter
590 590 def api_key(self, val):
591 591 # don't allow to set API key this is deprecated for now
592 592 self._api_key = None
593 593
594 594 @property
595 595 def firstname(self):
596 596 # alias for future
597 597 return self.name
598 598
599 599 @property
600 600 def emails(self):
601 601 other = UserEmailMap.query().filter(UserEmailMap.user==self).all()
602 602 return [self.email] + [x.email for x in other]
603 603
604 604 @property
605 605 def auth_tokens(self):
606 606 return [x.api_key for x in self.extra_auth_tokens]
607 607
608 608 @property
609 609 def extra_auth_tokens(self):
610 610 return UserApiKeys.query().filter(UserApiKeys.user == self).all()
611 611
612 612 @property
613 613 def feed_token(self):
614 614 return self.get_feed_token()
615 615
616 616 def get_feed_token(self):
617 617 feed_tokens = UserApiKeys.query()\
618 618 .filter(UserApiKeys.user == self)\
619 619 .filter(UserApiKeys.role == UserApiKeys.ROLE_FEED)\
620 620 .all()
621 621 if feed_tokens:
622 622 return feed_tokens[0].api_key
623 623 return 'NO_FEED_TOKEN_AVAILABLE'
624 624
625 625 @classmethod
626 626 def extra_valid_auth_tokens(cls, user, role=None):
627 627 tokens = UserApiKeys.query().filter(UserApiKeys.user == user)\
628 628 .filter(or_(UserApiKeys.expires == -1,
629 629 UserApiKeys.expires >= time.time()))
630 630 if role:
631 631 tokens = tokens.filter(or_(UserApiKeys.role == role,
632 632 UserApiKeys.role == UserApiKeys.ROLE_ALL))
633 633 return tokens.all()
634 634
635 635 def authenticate_by_token(self, auth_token, roles=None, scope_repo_id=None):
636 636 from rhodecode.lib import auth
637 637
638 638 log.debug('Trying to authenticate user: %s via auth-token, '
639 639 'and roles: %s', self, roles)
640 640
641 641 if not auth_token:
642 642 return False
643 643
644 644 crypto_backend = auth.crypto_backend()
645 645
646 646 roles = (roles or []) + [UserApiKeys.ROLE_ALL]
647 647 tokens_q = UserApiKeys.query()\
648 648 .filter(UserApiKeys.user_id == self.user_id)\
649 649 .filter(or_(UserApiKeys.expires == -1,
650 650 UserApiKeys.expires >= time.time()))
651 651
652 652 tokens_q = tokens_q.filter(UserApiKeys.role.in_(roles))
653 653
654 654 plain_tokens = []
655 655 hash_tokens = []
656 656
657 657 for token in tokens_q.all():
658 658 # verify scope first
659 659 if token.repo_id:
660 660 # token has a scope, we need to verify it
661 661 if scope_repo_id != token.repo_id:
662 662 log.debug(
663 663 'Scope mismatch: token has a set repo scope: %s, '
664 664 'and calling scope is:%s, skipping further checks',
665 665 token.repo, scope_repo_id)
666 666 # token has a scope, and it doesn't match, skip token
667 667 continue
668 668
669 669 if token.api_key.startswith(crypto_backend.ENC_PREF):
670 670 hash_tokens.append(token.api_key)
671 671 else:
672 672 plain_tokens.append(token.api_key)
673 673
674 674 is_plain_match = auth_token in plain_tokens
675 675 if is_plain_match:
676 676 return True
677 677
678 678 for hashed in hash_tokens:
679 679 # TODO(marcink): this is expensive to calculate, but most secure
680 680 match = crypto_backend.hash_check(auth_token, hashed)
681 681 if match:
682 682 return True
683 683
684 684 return False
685 685
686 686 @property
687 687 def ip_addresses(self):
688 688 ret = UserIpMap.query().filter(UserIpMap.user == self).all()
689 689 return [x.ip_addr for x in ret]
690 690
691 691 @property
692 692 def username_and_name(self):
693 693 return '%s (%s %s)' % (self.username, self.firstname, self.lastname)
694 694
695 695 @property
696 696 def username_or_name_or_email(self):
697 697 full_name = self.full_name if self.full_name is not ' ' else None
698 698 return self.username or full_name or self.email
699 699
700 700 @property
701 701 def full_name(self):
702 702 return '%s %s' % (self.firstname, self.lastname)
703 703
704 704 @property
705 705 def full_name_or_username(self):
706 706 return ('%s %s' % (self.firstname, self.lastname)
707 707 if (self.firstname and self.lastname) else self.username)
708 708
709 709 @property
710 710 def full_contact(self):
711 711 return '%s %s <%s>' % (self.firstname, self.lastname, self.email)
712 712
713 713 @property
714 714 def short_contact(self):
715 715 return '%s %s' % (self.firstname, self.lastname)
716 716
717 717 @property
718 718 def is_admin(self):
719 719 return self.admin
720 720
721 721 @property
722 722 def AuthUser(self):
723 723 """
724 724 Returns instance of AuthUser for this user
725 725 """
726 726 from rhodecode.lib.auth import AuthUser
727 727 return AuthUser(user_id=self.user_id, username=self.username)
728 728
729 729 @hybrid_property
730 730 def user_data(self):
731 731 if not self._user_data:
732 732 return {}
733 733
734 734 try:
735 735 return json.loads(self._user_data)
736 736 except TypeError:
737 737 return {}
738 738
739 739 @user_data.setter
740 740 def user_data(self, val):
741 741 if not isinstance(val, dict):
742 742 raise Exception('user_data must be dict, got %s' % type(val))
743 743 try:
744 744 self._user_data = json.dumps(val)
745 745 except Exception:
746 746 log.error(traceback.format_exc())
747 747
748 748 @classmethod
749 749 def get_by_username(cls, username, case_insensitive=False,
750 750 cache=False, identity_cache=False):
751 751 session = Session()
752 752
753 753 if case_insensitive:
754 754 q = cls.query().filter(
755 755 func.lower(cls.username) == func.lower(username))
756 756 else:
757 757 q = cls.query().filter(cls.username == username)
758 758
759 759 if cache:
760 760 if identity_cache:
761 761 val = cls.identity_cache(session, 'username', username)
762 762 if val:
763 763 return val
764 764 else:
765 765 cache_key = "get_user_by_name_%s" % _hash_key(username)
766 766 q = q.options(
767 767 FromCache("sql_cache_short", cache_key))
768 768
769 769 return q.scalar()
770 770
771 771 @classmethod
772 772 def get_by_auth_token(cls, auth_token, cache=False):
773 773 q = UserApiKeys.query()\
774 774 .filter(UserApiKeys.api_key == auth_token)\
775 775 .filter(or_(UserApiKeys.expires == -1,
776 776 UserApiKeys.expires >= time.time()))
777 777 if cache:
778 778 q = q.options(
779 779 FromCache("sql_cache_short", "get_auth_token_%s" % auth_token))
780 780
781 781 match = q.first()
782 782 if match:
783 783 return match.user
784 784
785 785 @classmethod
786 786 def get_by_email(cls, email, case_insensitive=False, cache=False):
787 787
788 788 if case_insensitive:
789 789 q = cls.query().filter(func.lower(cls.email) == func.lower(email))
790 790
791 791 else:
792 792 q = cls.query().filter(cls.email == email)
793 793
794 794 email_key = _hash_key(email)
795 795 if cache:
796 796 q = q.options(
797 797 FromCache("sql_cache_short", "get_email_key_%s" % email_key))
798 798
799 799 ret = q.scalar()
800 800 if ret is None:
801 801 q = UserEmailMap.query()
802 802 # try fetching in alternate email map
803 803 if case_insensitive:
804 804 q = q.filter(func.lower(UserEmailMap.email) == func.lower(email))
805 805 else:
806 806 q = q.filter(UserEmailMap.email == email)
807 807 q = q.options(joinedload(UserEmailMap.user))
808 808 if cache:
809 809 q = q.options(
810 810 FromCache("sql_cache_short", "get_email_map_key_%s" % email_key))
811 811 ret = getattr(q.scalar(), 'user', None)
812 812
813 813 return ret
814 814
815 815 @classmethod
816 816 def get_from_cs_author(cls, author):
817 817 """
818 818 Tries to get User objects out of commit author string
819 819
820 820 :param author:
821 821 """
822 822 from rhodecode.lib.helpers import email, author_name
823 823 # Valid email in the attribute passed, see if they're in the system
824 824 _email = email(author)
825 825 if _email:
826 826 user = cls.get_by_email(_email, case_insensitive=True)
827 827 if user:
828 828 return user
829 829 # Maybe we can match by username?
830 830 _author = author_name(author)
831 831 user = cls.get_by_username(_author, case_insensitive=True)
832 832 if user:
833 833 return user
834 834
835 835 def update_userdata(self, **kwargs):
836 836 usr = self
837 837 old = usr.user_data
838 838 old.update(**kwargs)
839 839 usr.user_data = old
840 840 Session().add(usr)
841 841 log.debug('updated userdata with ', kwargs)
842 842
843 843 def update_lastlogin(self):
844 844 """Update user lastlogin"""
845 845 self.last_login = datetime.datetime.now()
846 846 Session().add(self)
847 847 log.debug('updated user %s lastlogin', self.username)
848 848
849 849 def update_lastactivity(self):
850 850 """Update user lastactivity"""
851 851 self.last_activity = datetime.datetime.now()
852 852 Session().add(self)
853 853 log.debug('updated user %s lastactivity', self.username)
854 854
855 855 def update_password(self, new_password):
856 856 from rhodecode.lib.auth import get_crypt_password
857 857
858 858 self.password = get_crypt_password(new_password)
859 859 Session().add(self)
860 860
861 861 @classmethod
862 862 def get_first_super_admin(cls):
863 863 user = User.query().filter(User.admin == true()).first()
864 864 if user is None:
865 865 raise Exception('FATAL: Missing administrative account!')
866 866 return user
867 867
868 868 @classmethod
869 869 def get_all_super_admins(cls):
870 870 """
871 871 Returns all admin accounts sorted by username
872 872 """
873 873 return User.query().filter(User.admin == true())\
874 874 .order_by(User.username.asc()).all()
875 875
876 876 @classmethod
877 877 def get_default_user(cls, cache=False, refresh=False):
878 878 user = User.get_by_username(User.DEFAULT_USER, cache=cache)
879 879 if user is None:
880 880 raise Exception('FATAL: Missing default account!')
881 881 if refresh:
882 882 # The default user might be based on outdated state which
883 883 # has been loaded from the cache.
884 884 # A call to refresh() ensures that the
885 885 # latest state from the database is used.
886 886 Session().refresh(user)
887 887 return user
888 888
889 889 def _get_default_perms(self, user, suffix=''):
890 890 from rhodecode.model.permission import PermissionModel
891 891 return PermissionModel().get_default_perms(user.user_perms, suffix)
892 892
893 893 def get_default_perms(self, suffix=''):
894 894 return self._get_default_perms(self, suffix)
895 895
896 896 def get_api_data(self, include_secrets=False, details='full'):
897 897 """
898 898 Common function for generating user related data for API
899 899
900 900 :param include_secrets: By default secrets in the API data will be replaced
901 901 by a placeholder value to prevent exposing this data by accident. In case
902 902 this data shall be exposed, set this flag to ``True``.
903 903
904 904 :param details: details can be 'basic|full' basic gives only a subset of
905 905 the available user information that includes user_id, name and emails.
906 906 """
907 907 user = self
908 908 user_data = self.user_data
909 909 data = {
910 910 'user_id': user.user_id,
911 911 'username': user.username,
912 912 'firstname': user.name,
913 913 'lastname': user.lastname,
914 914 'email': user.email,
915 915 'emails': user.emails,
916 916 }
917 917 if details == 'basic':
918 918 return data
919 919
920 920 api_key_length = 40
921 921 api_key_replacement = '*' * api_key_length
922 922
923 923 extras = {
924 924 'api_keys': [api_key_replacement],
925 925 'auth_tokens': [api_key_replacement],
926 926 'active': user.active,
927 927 'admin': user.admin,
928 928 'extern_type': user.extern_type,
929 929 'extern_name': user.extern_name,
930 930 'last_login': user.last_login,
931 931 'last_activity': user.last_activity,
932 932 'ip_addresses': user.ip_addresses,
933 933 'language': user_data.get('language')
934 934 }
935 935 data.update(extras)
936 936
937 937 if include_secrets:
938 938 data['api_keys'] = user.auth_tokens
939 939 data['auth_tokens'] = user.extra_auth_tokens
940 940 return data
941 941
942 942 def __json__(self):
943 943 data = {
944 944 'full_name': self.full_name,
945 945 'full_name_or_username': self.full_name_or_username,
946 946 'short_contact': self.short_contact,
947 947 'full_contact': self.full_contact,
948 948 }
949 949 data.update(self.get_api_data())
950 950 return data
951 951
952 952
953 953 class UserApiKeys(Base, BaseModel):
954 954 __tablename__ = 'user_api_keys'
955 955 __table_args__ = (
956 956 Index('uak_api_key_idx', 'api_key'),
957 957 Index('uak_api_key_expires_idx', 'api_key', 'expires'),
958 958 UniqueConstraint('api_key'),
959 959 {'extend_existing': True, 'mysql_engine': 'InnoDB',
960 960 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
961 961 )
962 962 __mapper_args__ = {}
963 963
964 964 # ApiKey role
965 965 ROLE_ALL = 'token_role_all'
966 966 ROLE_HTTP = 'token_role_http'
967 967 ROLE_VCS = 'token_role_vcs'
968 968 ROLE_API = 'token_role_api'
969 969 ROLE_FEED = 'token_role_feed'
970 970 ROLE_PASSWORD_RESET = 'token_password_reset'
971 971
972 972 ROLES = [ROLE_ALL, ROLE_HTTP, ROLE_VCS, ROLE_API, ROLE_FEED]
973 973
974 974 user_api_key_id = Column("user_api_key_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
975 975 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=True, unique=None, default=None)
976 976 api_key = Column("api_key", String(255), nullable=False, unique=True)
977 977 description = Column('description', UnicodeText().with_variant(UnicodeText(1024), 'mysql'))
978 978 expires = Column('expires', Float(53), nullable=False)
979 979 role = Column('role', String(255), nullable=True)
980 980 created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
981 981
982 982 # scope columns
983 983 repo_id = Column(
984 984 'repo_id', Integer(), ForeignKey('repositories.repo_id'),
985 985 nullable=True, unique=None, default=None)
986 986 repo = relationship('Repository', lazy='joined')
987 987
988 988 repo_group_id = Column(
989 989 'repo_group_id', Integer(), ForeignKey('groups.group_id'),
990 990 nullable=True, unique=None, default=None)
991 991 repo_group = relationship('RepoGroup', lazy='joined')
992 992
993 993 user = relationship('User', lazy='joined')
994 994
995 995 def __unicode__(self):
996 996 return u"<%s('%s')>" % (self.__class__.__name__, self.role)
997 997
998 998 def __json__(self):
999 999 data = {
1000 1000 'auth_token': self.api_key,
1001 1001 'role': self.role,
1002 1002 'scope': self.scope_humanized,
1003 1003 'expired': self.expired
1004 1004 }
1005 1005 return data
1006 1006
1007 1007 @property
1008 1008 def expired(self):
1009 1009 if self.expires == -1:
1010 1010 return False
1011 1011 return time.time() > self.expires
1012 1012
1013 1013 @classmethod
1014 1014 def _get_role_name(cls, role):
1015 1015 return {
1016 1016 cls.ROLE_ALL: _('all'),
1017 1017 cls.ROLE_HTTP: _('http/web interface'),
1018 1018 cls.ROLE_VCS: _('vcs (git/hg/svn protocol)'),
1019 1019 cls.ROLE_API: _('api calls'),
1020 1020 cls.ROLE_FEED: _('feed access'),
1021 1021 }.get(role, role)
1022 1022
1023 1023 @property
1024 1024 def role_humanized(self):
1025 1025 return self._get_role_name(self.role)
1026 1026
1027 1027 def _get_scope(self):
1028 1028 if self.repo:
1029 1029 return repr(self.repo)
1030 1030 if self.repo_group:
1031 1031 return repr(self.repo_group) + ' (recursive)'
1032 1032 return 'global'
1033 1033
1034 1034 @property
1035 1035 def scope_humanized(self):
1036 1036 return self._get_scope()
1037 1037
1038 1038
1039 1039 class UserEmailMap(Base, BaseModel):
1040 1040 __tablename__ = 'user_email_map'
1041 1041 __table_args__ = (
1042 1042 Index('uem_email_idx', 'email'),
1043 1043 UniqueConstraint('email'),
1044 1044 {'extend_existing': True, 'mysql_engine': 'InnoDB',
1045 1045 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
1046 1046 )
1047 1047 __mapper_args__ = {}
1048 1048
1049 1049 email_id = Column("email_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
1050 1050 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=True, unique=None, default=None)
1051 1051 _email = Column("email", String(255), nullable=True, unique=False, default=None)
1052 1052 user = relationship('User', lazy='joined')
1053 1053
1054 1054 @validates('_email')
1055 1055 def validate_email(self, key, email):
1056 1056 # check if this email is not main one
1057 1057 main_email = Session().query(User).filter(User.email == email).scalar()
1058 1058 if main_email is not None:
1059 1059 raise AttributeError('email %s is present is user table' % email)
1060 1060 return email
1061 1061
1062 1062 @hybrid_property
1063 1063 def email(self):
1064 1064 return self._email
1065 1065
1066 1066 @email.setter
1067 1067 def email(self, val):
1068 1068 self._email = val.lower() if val else None
1069 1069
1070 1070
1071 1071 class UserIpMap(Base, BaseModel):
1072 1072 __tablename__ = 'user_ip_map'
1073 1073 __table_args__ = (
1074 1074 UniqueConstraint('user_id', 'ip_addr'),
1075 1075 {'extend_existing': True, 'mysql_engine': 'InnoDB',
1076 1076 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
1077 1077 )
1078 1078 __mapper_args__ = {}
1079 1079
1080 1080 ip_id = Column("ip_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
1081 1081 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=True, unique=None, default=None)
1082 1082 ip_addr = Column("ip_addr", String(255), nullable=True, unique=False, default=None)
1083 1083 active = Column("active", Boolean(), nullable=True, unique=None, default=True)
1084 1084 description = Column("description", String(10000), nullable=True, unique=None, default=None)
1085 1085 user = relationship('User', lazy='joined')
1086 1086
1087 1087 @classmethod
1088 1088 def _get_ip_range(cls, ip_addr):
1089 1089 net = ipaddress.ip_network(ip_addr, strict=False)
1090 1090 return [str(net.network_address), str(net.broadcast_address)]
1091 1091
1092 1092 def __json__(self):
1093 1093 return {
1094 1094 'ip_addr': self.ip_addr,
1095 1095 'ip_range': self._get_ip_range(self.ip_addr),
1096 1096 }
1097 1097
1098 1098 def __unicode__(self):
1099 1099 return u"<%s('user_id:%s=>%s')>" % (self.__class__.__name__,
1100 1100 self.user_id, self.ip_addr)
1101 1101
1102 1102
1103 1103 class UserLog(Base, BaseModel):
1104 1104 __tablename__ = 'user_logs'
1105 1105 __table_args__ = (
1106 1106 {'extend_existing': True, 'mysql_engine': 'InnoDB',
1107 1107 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
1108 1108 )
1109 1109 VERSION_1 = 'v1'
1110 1110 VERSION_2 = 'v2'
1111 1111 VERSIONS = [VERSION_1, VERSION_2]
1112 1112
1113 1113 user_log_id = Column("user_log_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
1114 1114 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=True, unique=None, default=None)
1115 1115 username = Column("username", String(255), nullable=True, unique=None, default=None)
1116 1116 repository_id = Column("repository_id", Integer(), ForeignKey('repositories.repo_id'), nullable=True)
1117 1117 repository_name = Column("repository_name", String(255), nullable=True, unique=None, default=None)
1118 1118 user_ip = Column("user_ip", String(255), nullable=True, unique=None, default=None)
1119 1119 action = Column("action", Text().with_variant(Text(1200000), 'mysql'), nullable=True, unique=None, default=None)
1120 1120 action_date = Column("action_date", DateTime(timezone=False), nullable=True, unique=None, default=None)
1121 1121
1122 1122 version = Column("version", String(255), nullable=True, default=VERSION_1)
1123 1123 user_data = Column('user_data_json', MutationObj.as_mutable(JsonType(dialect_map=dict(mysql=UnicodeText(16384)))))
1124 1124 action_data = Column('action_data_json', MutationObj.as_mutable(JsonType(dialect_map=dict(mysql=UnicodeText(16384)))))
1125 1125
1126 1126 def __unicode__(self):
1127 1127 return u"<%s('id:%s:%s')>" % (
1128 1128 self.__class__.__name__, self.repository_name, self.action)
1129 1129
1130 1130 def __json__(self):
1131 1131 return {
1132 1132 'user_id': self.user_id,
1133 1133 'username': self.username,
1134 1134 'repository_id': self.repository_id,
1135 1135 'repository_name': self.repository_name,
1136 1136 'user_ip': self.user_ip,
1137 1137 'action_date': self.action_date,
1138 1138 'action': self.action,
1139 1139 }
1140 1140
1141 1141 @property
1142 1142 def action_as_day(self):
1143 1143 return datetime.date(*self.action_date.timetuple()[:3])
1144 1144
1145 1145 user = relationship('User')
1146 1146 repository = relationship('Repository', cascade='')
1147 1147
1148 1148
1149 1149 class UserGroup(Base, BaseModel):
1150 1150 __tablename__ = 'users_groups'
1151 1151 __table_args__ = (
1152 1152 {'extend_existing': True, 'mysql_engine': 'InnoDB',
1153 1153 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
1154 1154 )
1155 1155
1156 1156 users_group_id = Column("users_group_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
1157 1157 users_group_name = Column("users_group_name", String(255), nullable=False, unique=True, default=None)
1158 1158 user_group_description = Column("user_group_description", String(10000), nullable=True, unique=None, default=None)
1159 1159 users_group_active = Column("users_group_active", Boolean(), nullable=True, unique=None, default=None)
1160 1160 inherit_default_permissions = Column("users_group_inherit_default_permissions", Boolean(), nullable=False, unique=None, default=True)
1161 1161 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=False, default=None)
1162 1162 created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
1163 1163 _group_data = Column("group_data", LargeBinary(), nullable=True) # JSON data
1164 1164
1165 1165 members = relationship('UserGroupMember', cascade="all, delete, delete-orphan", lazy="joined")
1166 1166 users_group_to_perm = relationship('UserGroupToPerm', cascade='all')
1167 1167 users_group_repo_to_perm = relationship('UserGroupRepoToPerm', cascade='all')
1168 1168 users_group_repo_group_to_perm = relationship('UserGroupRepoGroupToPerm', cascade='all')
1169 1169 user_user_group_to_perm = relationship('UserUserGroupToPerm', cascade='all')
1170 1170 user_group_user_group_to_perm = relationship('UserGroupUserGroupToPerm ', primaryjoin="UserGroupUserGroupToPerm.target_user_group_id==UserGroup.users_group_id", cascade='all')
1171 1171
1172 1172 user = relationship('User')
1173 1173
1174 1174 @hybrid_property
1175 1175 def group_data(self):
1176 1176 if not self._group_data:
1177 1177 return {}
1178 1178
1179 1179 try:
1180 1180 return json.loads(self._group_data)
1181 1181 except TypeError:
1182 1182 return {}
1183 1183
1184 1184 @group_data.setter
1185 1185 def group_data(self, val):
1186 1186 try:
1187 1187 self._group_data = json.dumps(val)
1188 1188 except Exception:
1189 1189 log.error(traceback.format_exc())
1190 1190
1191 1191 def __unicode__(self):
1192 1192 return u"<%s('id:%s:%s')>" % (self.__class__.__name__,
1193 1193 self.users_group_id,
1194 1194 self.users_group_name)
1195 1195
1196 1196 @classmethod
1197 1197 def get_by_group_name(cls, group_name, cache=False,
1198 1198 case_insensitive=False):
1199 1199 if case_insensitive:
1200 1200 q = cls.query().filter(func.lower(cls.users_group_name) ==
1201 1201 func.lower(group_name))
1202 1202
1203 1203 else:
1204 1204 q = cls.query().filter(cls.users_group_name == group_name)
1205 1205 if cache:
1206 1206 q = q.options(
1207 1207 FromCache("sql_cache_short", "get_group_%s" % _hash_key(group_name)))
1208 1208 return q.scalar()
1209 1209
1210 1210 @classmethod
1211 1211 def get(cls, user_group_id, cache=False):
1212 1212 user_group = cls.query()
1213 1213 if cache:
1214 1214 user_group = user_group.options(
1215 1215 FromCache("sql_cache_short", "get_users_group_%s" % user_group_id))
1216 1216 return user_group.get(user_group_id)
1217 1217
1218 1218 def permissions(self, with_admins=True, with_owner=True):
1219 1219 q = UserUserGroupToPerm.query().filter(UserUserGroupToPerm.user_group == self)
1220 1220 q = q.options(joinedload(UserUserGroupToPerm.user_group),
1221 1221 joinedload(UserUserGroupToPerm.user),
1222 1222 joinedload(UserUserGroupToPerm.permission),)
1223 1223
1224 1224 # get owners and admins and permissions. We do a trick of re-writing
1225 1225 # objects from sqlalchemy to named-tuples due to sqlalchemy session
1226 1226 # has a global reference and changing one object propagates to all
1227 1227 # others. This means if admin is also an owner admin_row that change
1228 1228 # would propagate to both objects
1229 1229 perm_rows = []
1230 1230 for _usr in q.all():
1231 1231 usr = AttributeDict(_usr.user.get_dict())
1232 1232 usr.permission = _usr.permission.permission_name
1233 1233 perm_rows.append(usr)
1234 1234
1235 1235 # filter the perm rows by 'default' first and then sort them by
1236 1236 # admin,write,read,none permissions sorted again alphabetically in
1237 1237 # each group
1238 1238 perm_rows = sorted(perm_rows, key=display_sort)
1239 1239
1240 1240 _admin_perm = 'usergroup.admin'
1241 1241 owner_row = []
1242 1242 if with_owner:
1243 1243 usr = AttributeDict(self.user.get_dict())
1244 1244 usr.owner_row = True
1245 1245 usr.permission = _admin_perm
1246 1246 owner_row.append(usr)
1247 1247
1248 1248 super_admin_rows = []
1249 1249 if with_admins:
1250 1250 for usr in User.get_all_super_admins():
1251 1251 # if this admin is also owner, don't double the record
1252 1252 if usr.user_id == owner_row[0].user_id:
1253 1253 owner_row[0].admin_row = True
1254 1254 else:
1255 1255 usr = AttributeDict(usr.get_dict())
1256 1256 usr.admin_row = True
1257 1257 usr.permission = _admin_perm
1258 1258 super_admin_rows.append(usr)
1259 1259
1260 1260 return super_admin_rows + owner_row + perm_rows
1261 1261
1262 1262 def permission_user_groups(self):
1263 1263 q = UserGroupUserGroupToPerm.query().filter(UserGroupUserGroupToPerm.target_user_group == self)
1264 1264 q = q.options(joinedload(UserGroupUserGroupToPerm.user_group),
1265 1265 joinedload(UserGroupUserGroupToPerm.target_user_group),
1266 1266 joinedload(UserGroupUserGroupToPerm.permission),)
1267 1267
1268 1268 perm_rows = []
1269 1269 for _user_group in q.all():
1270 1270 usr = AttributeDict(_user_group.user_group.get_dict())
1271 1271 usr.permission = _user_group.permission.permission_name
1272 1272 perm_rows.append(usr)
1273 1273
1274 1274 return perm_rows
1275 1275
1276 1276 def _get_default_perms(self, user_group, suffix=''):
1277 1277 from rhodecode.model.permission import PermissionModel
1278 1278 return PermissionModel().get_default_perms(user_group.users_group_to_perm, suffix)
1279 1279
1280 1280 def get_default_perms(self, suffix=''):
1281 1281 return self._get_default_perms(self, suffix)
1282 1282
1283 1283 def get_api_data(self, with_group_members=True, include_secrets=False):
1284 1284 """
1285 1285 :param include_secrets: See :meth:`User.get_api_data`, this parameter is
1286 1286 basically forwarded.
1287 1287
1288 1288 """
1289 1289 user_group = self
1290 1290 data = {
1291 1291 'users_group_id': user_group.users_group_id,
1292 1292 'group_name': user_group.users_group_name,
1293 1293 'group_description': user_group.user_group_description,
1294 1294 'active': user_group.users_group_active,
1295 1295 'owner': user_group.user.username,
1296 1296 'owner_email': user_group.user.email,
1297 1297 }
1298 1298
1299 1299 if with_group_members:
1300 1300 users = []
1301 1301 for user in user_group.members:
1302 1302 user = user.user
1303 1303 users.append(user.get_api_data(include_secrets=include_secrets))
1304 1304 data['users'] = users
1305 1305
1306 1306 return data
1307 1307
1308 1308
1309 1309 class UserGroupMember(Base, BaseModel):
1310 1310 __tablename__ = 'users_groups_members'
1311 1311 __table_args__ = (
1312 1312 {'extend_existing': True, 'mysql_engine': 'InnoDB',
1313 1313 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
1314 1314 )
1315 1315
1316 1316 users_group_member_id = Column("users_group_member_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
1317 1317 users_group_id = Column("users_group_id", Integer(), ForeignKey('users_groups.users_group_id'), nullable=False, unique=None, default=None)
1318 1318 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=None, default=None)
1319 1319
1320 1320 user = relationship('User', lazy='joined')
1321 1321 users_group = relationship('UserGroup')
1322 1322
1323 1323 def __init__(self, gr_id='', u_id=''):
1324 1324 self.users_group_id = gr_id
1325 1325 self.user_id = u_id
1326 1326
1327 1327
1328 1328 class RepositoryField(Base, BaseModel):
1329 1329 __tablename__ = 'repositories_fields'
1330 1330 __table_args__ = (
1331 1331 UniqueConstraint('repository_id', 'field_key'), # no-multi field
1332 1332 {'extend_existing': True, 'mysql_engine': 'InnoDB',
1333 1333 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
1334 1334 )
1335 1335 PREFIX = 'ex_' # prefix used in form to not conflict with already existing fields
1336 1336
1337 1337 repo_field_id = Column("repo_field_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
1338 1338 repository_id = Column("repository_id", Integer(), ForeignKey('repositories.repo_id'), nullable=False, unique=None, default=None)
1339 1339 field_key = Column("field_key", String(250))
1340 1340 field_label = Column("field_label", String(1024), nullable=False)
1341 1341 field_value = Column("field_value", String(10000), nullable=False)
1342 1342 field_desc = Column("field_desc", String(1024), nullable=False)
1343 1343 field_type = Column("field_type", String(255), nullable=False, unique=None)
1344 1344 created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
1345 1345
1346 1346 repository = relationship('Repository')
1347 1347
1348 1348 @property
1349 1349 def field_key_prefixed(self):
1350 1350 return 'ex_%s' % self.field_key
1351 1351
1352 1352 @classmethod
1353 1353 def un_prefix_key(cls, key):
1354 1354 if key.startswith(cls.PREFIX):
1355 1355 return key[len(cls.PREFIX):]
1356 1356 return key
1357 1357
1358 1358 @classmethod
1359 1359 def get_by_key_name(cls, key, repo):
1360 1360 row = cls.query()\
1361 1361 .filter(cls.repository == repo)\
1362 1362 .filter(cls.field_key == key).scalar()
1363 1363 return row
1364 1364
1365 1365
1366 1366 class Repository(Base, BaseModel):
1367 1367 __tablename__ = 'repositories'
1368 1368 __table_args__ = (
1369 1369 Index('r_repo_name_idx', 'repo_name', mysql_length=255),
1370 1370 {'extend_existing': True, 'mysql_engine': 'InnoDB',
1371 1371 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
1372 1372 )
1373 1373 DEFAULT_CLONE_URI = '{scheme}://{user}@{netloc}/{repo}'
1374 1374 DEFAULT_CLONE_URI_ID = '{scheme}://{user}@{netloc}/_{repoid}'
1375 1375
1376 1376 STATE_CREATED = 'repo_state_created'
1377 1377 STATE_PENDING = 'repo_state_pending'
1378 1378 STATE_ERROR = 'repo_state_error'
1379 1379
1380 1380 LOCK_AUTOMATIC = 'lock_auto'
1381 1381 LOCK_API = 'lock_api'
1382 1382 LOCK_WEB = 'lock_web'
1383 1383 LOCK_PULL = 'lock_pull'
1384 1384
1385 1385 NAME_SEP = URL_SEP
1386 1386
1387 1387 repo_id = Column(
1388 1388 "repo_id", Integer(), nullable=False, unique=True, default=None,
1389 1389 primary_key=True)
1390 1390 _repo_name = Column(
1391 1391 "repo_name", Text(), nullable=False, default=None)
1392 1392 _repo_name_hash = Column(
1393 1393 "repo_name_hash", String(255), nullable=False, unique=True)
1394 1394 repo_state = Column("repo_state", String(255), nullable=True)
1395 1395
1396 1396 clone_uri = Column(
1397 1397 "clone_uri", EncryptedTextValue(), nullable=True, unique=False,
1398 1398 default=None)
1399 1399 repo_type = Column(
1400 1400 "repo_type", String(255), nullable=False, unique=False, default=None)
1401 1401 user_id = Column(
1402 1402 "user_id", Integer(), ForeignKey('users.user_id'), nullable=False,
1403 1403 unique=False, default=None)
1404 1404 private = Column(
1405 1405 "private", Boolean(), nullable=True, unique=None, default=None)
1406 1406 enable_statistics = Column(
1407 1407 "statistics", Boolean(), nullable=True, unique=None, default=True)
1408 1408 enable_downloads = Column(
1409 1409 "downloads", Boolean(), nullable=True, unique=None, default=True)
1410 1410 description = Column(
1411 1411 "description", String(10000), nullable=True, unique=None, default=None)
1412 1412 created_on = Column(
1413 1413 'created_on', DateTime(timezone=False), nullable=True, unique=None,
1414 1414 default=datetime.datetime.now)
1415 1415 updated_on = Column(
1416 1416 'updated_on', DateTime(timezone=False), nullable=True, unique=None,
1417 1417 default=datetime.datetime.now)
1418 1418 _landing_revision = Column(
1419 1419 "landing_revision", String(255), nullable=False, unique=False,
1420 1420 default=None)
1421 1421 enable_locking = Column(
1422 1422 "enable_locking", Boolean(), nullable=False, unique=None,
1423 1423 default=False)
1424 1424 _locked = Column(
1425 1425 "locked", String(255), nullable=True, unique=False, default=None)
1426 1426 _changeset_cache = Column(
1427 1427 "changeset_cache", LargeBinary(), nullable=True) # JSON data
1428 1428
1429 1429 fork_id = Column(
1430 1430 "fork_id", Integer(), ForeignKey('repositories.repo_id'),
1431 1431 nullable=True, unique=False, default=None)
1432 1432 group_id = Column(
1433 1433 "group_id", Integer(), ForeignKey('groups.group_id'), nullable=True,
1434 1434 unique=False, default=None)
1435 1435
1436 1436 user = relationship('User', lazy='joined')
1437 1437 fork = relationship('Repository', remote_side=repo_id, lazy='joined')
1438 1438 group = relationship('RepoGroup', lazy='joined')
1439 1439 repo_to_perm = relationship(
1440 1440 'UserRepoToPerm', cascade='all',
1441 1441 order_by='UserRepoToPerm.repo_to_perm_id')
1442 1442 users_group_to_perm = relationship('UserGroupRepoToPerm', cascade='all')
1443 1443 stats = relationship('Statistics', cascade='all', uselist=False)
1444 1444
1445 1445 followers = relationship(
1446 1446 'UserFollowing',
1447 1447 primaryjoin='UserFollowing.follows_repo_id==Repository.repo_id',
1448 1448 cascade='all')
1449 1449 extra_fields = relationship(
1450 1450 'RepositoryField', cascade="all, delete, delete-orphan")
1451 1451 logs = relationship('UserLog')
1452 1452 comments = relationship(
1453 1453 'ChangesetComment', cascade="all, delete, delete-orphan")
1454 1454 pull_requests_source = relationship(
1455 1455 'PullRequest',
1456 1456 primaryjoin='PullRequest.source_repo_id==Repository.repo_id',
1457 1457 cascade="all, delete, delete-orphan")
1458 1458 pull_requests_target = relationship(
1459 1459 'PullRequest',
1460 1460 primaryjoin='PullRequest.target_repo_id==Repository.repo_id',
1461 1461 cascade="all, delete, delete-orphan")
1462 1462 ui = relationship('RepoRhodeCodeUi', cascade="all")
1463 1463 settings = relationship('RepoRhodeCodeSetting', cascade="all")
1464 1464 integrations = relationship('Integration',
1465 1465 cascade="all, delete, delete-orphan")
1466 1466
1467 1467 def __unicode__(self):
1468 1468 return u"<%s('%s:%s')>" % (self.__class__.__name__, self.repo_id,
1469 1469 safe_unicode(self.repo_name))
1470 1470
1471 1471 @hybrid_property
1472 1472 def landing_rev(self):
1473 1473 # always should return [rev_type, rev]
1474 1474 if self._landing_revision:
1475 1475 _rev_info = self._landing_revision.split(':')
1476 1476 if len(_rev_info) < 2:
1477 1477 _rev_info.insert(0, 'rev')
1478 1478 return [_rev_info[0], _rev_info[1]]
1479 1479 return [None, None]
1480 1480
1481 1481 @landing_rev.setter
1482 1482 def landing_rev(self, val):
1483 1483 if ':' not in val:
1484 1484 raise ValueError('value must be delimited with `:` and consist '
1485 1485 'of <rev_type>:<rev>, got %s instead' % val)
1486 1486 self._landing_revision = val
1487 1487
1488 1488 @hybrid_property
1489 1489 def locked(self):
1490 1490 if self._locked:
1491 1491 user_id, timelocked, reason = self._locked.split(':')
1492 1492 lock_values = int(user_id), timelocked, reason
1493 1493 else:
1494 1494 lock_values = [None, None, None]
1495 1495 return lock_values
1496 1496
1497 1497 @locked.setter
1498 1498 def locked(self, val):
1499 1499 if val and isinstance(val, (list, tuple)):
1500 1500 self._locked = ':'.join(map(str, val))
1501 1501 else:
1502 1502 self._locked = None
1503 1503
1504 1504 @hybrid_property
1505 1505 def changeset_cache(self):
1506 1506 from rhodecode.lib.vcs.backends.base import EmptyCommit
1507 1507 dummy = EmptyCommit().__json__()
1508 1508 if not self._changeset_cache:
1509 1509 return dummy
1510 1510 try:
1511 1511 return json.loads(self._changeset_cache)
1512 1512 except TypeError:
1513 1513 return dummy
1514 1514 except Exception:
1515 1515 log.error(traceback.format_exc())
1516 1516 return dummy
1517 1517
1518 1518 @changeset_cache.setter
1519 1519 def changeset_cache(self, val):
1520 1520 try:
1521 1521 self._changeset_cache = json.dumps(val)
1522 1522 except Exception:
1523 1523 log.error(traceback.format_exc())
1524 1524
1525 1525 @hybrid_property
1526 1526 def repo_name(self):
1527 1527 return self._repo_name
1528 1528
1529 1529 @repo_name.setter
1530 1530 def repo_name(self, value):
1531 1531 self._repo_name = value
1532 1532 self._repo_name_hash = hashlib.sha1(safe_str(value)).hexdigest()
1533 1533
1534 1534 @classmethod
1535 1535 def normalize_repo_name(cls, repo_name):
1536 1536 """
1537 1537 Normalizes os specific repo_name to the format internally stored inside
1538 1538 database using URL_SEP
1539 1539
1540 1540 :param cls:
1541 1541 :param repo_name:
1542 1542 """
1543 1543 return cls.NAME_SEP.join(repo_name.split(os.sep))
1544 1544
1545 1545 @classmethod
1546 1546 def get_by_repo_name(cls, repo_name, cache=False, identity_cache=False):
1547 1547 session = Session()
1548 1548 q = session.query(cls).filter(cls.repo_name == repo_name)
1549 1549
1550 1550 if cache:
1551 1551 if identity_cache:
1552 1552 val = cls.identity_cache(session, 'repo_name', repo_name)
1553 1553 if val:
1554 1554 return val
1555 1555 else:
1556 1556 cache_key = "get_repo_by_name_%s" % _hash_key(repo_name)
1557 1557 q = q.options(
1558 1558 FromCache("sql_cache_short", cache_key))
1559 1559
1560 1560 return q.scalar()
1561 1561
1562 1562 @classmethod
1563 1563 def get_by_full_path(cls, repo_full_path):
1564 1564 repo_name = repo_full_path.split(cls.base_path(), 1)[-1]
1565 1565 repo_name = cls.normalize_repo_name(repo_name)
1566 1566 return cls.get_by_repo_name(repo_name.strip(URL_SEP))
1567 1567
1568 1568 @classmethod
1569 1569 def get_repo_forks(cls, repo_id):
1570 1570 return cls.query().filter(Repository.fork_id == repo_id)
1571 1571
1572 1572 @classmethod
1573 1573 def base_path(cls):
1574 1574 """
1575 1575 Returns base path when all repos are stored
1576 1576
1577 1577 :param cls:
1578 1578 """
1579 1579 q = Session().query(RhodeCodeUi)\
1580 1580 .filter(RhodeCodeUi.ui_key == cls.NAME_SEP)
1581 1581 q = q.options(FromCache("sql_cache_short", "repository_repo_path"))
1582 1582 return q.one().ui_value
1583 1583
1584 1584 @classmethod
1585 1585 def is_valid(cls, repo_name):
1586 1586 """
1587 1587 returns True if given repo name is a valid filesystem repository
1588 1588
1589 1589 :param cls:
1590 1590 :param repo_name:
1591 1591 """
1592 1592 from rhodecode.lib.utils import is_valid_repo
1593 1593
1594 1594 return is_valid_repo(repo_name, cls.base_path())
1595 1595
1596 1596 @classmethod
1597 1597 def get_all_repos(cls, user_id=Optional(None), group_id=Optional(None),
1598 1598 case_insensitive=True):
1599 1599 q = Repository.query()
1600 1600
1601 1601 if not isinstance(user_id, Optional):
1602 1602 q = q.filter(Repository.user_id == user_id)
1603 1603
1604 1604 if not isinstance(group_id, Optional):
1605 1605 q = q.filter(Repository.group_id == group_id)
1606 1606
1607 1607 if case_insensitive:
1608 1608 q = q.order_by(func.lower(Repository.repo_name))
1609 1609 else:
1610 1610 q = q.order_by(Repository.repo_name)
1611 1611 return q.all()
1612 1612
1613 1613 @property
1614 1614 def forks(self):
1615 1615 """
1616 1616 Return forks of this repo
1617 1617 """
1618 1618 return Repository.get_repo_forks(self.repo_id)
1619 1619
1620 1620 @property
1621 1621 def parent(self):
1622 1622 """
1623 1623 Returns fork parent
1624 1624 """
1625 1625 return self.fork
1626 1626
1627 1627 @property
1628 1628 def just_name(self):
1629 1629 return self.repo_name.split(self.NAME_SEP)[-1]
1630 1630
1631 1631 @property
1632 1632 def groups_with_parents(self):
1633 1633 groups = []
1634 1634 if self.group is None:
1635 1635 return groups
1636 1636
1637 1637 cur_gr = self.group
1638 1638 groups.insert(0, cur_gr)
1639 1639 while 1:
1640 1640 gr = getattr(cur_gr, 'parent_group', None)
1641 1641 cur_gr = cur_gr.parent_group
1642 1642 if gr is None:
1643 1643 break
1644 1644 groups.insert(0, gr)
1645 1645
1646 1646 return groups
1647 1647
1648 1648 @property
1649 1649 def groups_and_repo(self):
1650 1650 return self.groups_with_parents, self
1651 1651
1652 1652 @LazyProperty
1653 1653 def repo_path(self):
1654 1654 """
1655 1655 Returns base full path for that repository means where it actually
1656 1656 exists on a filesystem
1657 1657 """
1658 1658 q = Session().query(RhodeCodeUi).filter(
1659 1659 RhodeCodeUi.ui_key == self.NAME_SEP)
1660 1660 q = q.options(FromCache("sql_cache_short", "repository_repo_path"))
1661 1661 return q.one().ui_value
1662 1662
1663 1663 @property
1664 1664 def repo_full_path(self):
1665 1665 p = [self.repo_path]
1666 1666 # we need to split the name by / since this is how we store the
1667 1667 # names in the database, but that eventually needs to be converted
1668 1668 # into a valid system path
1669 1669 p += self.repo_name.split(self.NAME_SEP)
1670 1670 return os.path.join(*map(safe_unicode, p))
1671 1671
1672 1672 @property
1673 1673 def cache_keys(self):
1674 1674 """
1675 1675 Returns associated cache keys for that repo
1676 1676 """
1677 1677 return CacheKey.query()\
1678 1678 .filter(CacheKey.cache_args == self.repo_name)\
1679 1679 .order_by(CacheKey.cache_key)\
1680 1680 .all()
1681 1681
1682 1682 def get_new_name(self, repo_name):
1683 1683 """
1684 1684 returns new full repository name based on assigned group and new new
1685 1685
1686 1686 :param group_name:
1687 1687 """
1688 1688 path_prefix = self.group.full_path_splitted if self.group else []
1689 1689 return self.NAME_SEP.join(path_prefix + [repo_name])
1690 1690
1691 1691 @property
1692 1692 def _config(self):
1693 1693 """
1694 1694 Returns db based config object.
1695 1695 """
1696 1696 from rhodecode.lib.utils import make_db_config
1697 1697 return make_db_config(clear_session=False, repo=self)
1698 1698
1699 1699 def permissions(self, with_admins=True, with_owner=True):
1700 1700 q = UserRepoToPerm.query().filter(UserRepoToPerm.repository == self)
1701 1701 q = q.options(joinedload(UserRepoToPerm.repository),
1702 1702 joinedload(UserRepoToPerm.user),
1703 1703 joinedload(UserRepoToPerm.permission),)
1704 1704
1705 1705 # get owners and admins and permissions. We do a trick of re-writing
1706 1706 # objects from sqlalchemy to named-tuples due to sqlalchemy session
1707 1707 # has a global reference and changing one object propagates to all
1708 1708 # others. This means if admin is also an owner admin_row that change
1709 1709 # would propagate to both objects
1710 1710 perm_rows = []
1711 1711 for _usr in q.all():
1712 1712 usr = AttributeDict(_usr.user.get_dict())
1713 1713 usr.permission = _usr.permission.permission_name
1714 1714 perm_rows.append(usr)
1715 1715
1716 1716 # filter the perm rows by 'default' first and then sort them by
1717 1717 # admin,write,read,none permissions sorted again alphabetically in
1718 1718 # each group
1719 1719 perm_rows = sorted(perm_rows, key=display_sort)
1720 1720
1721 1721 _admin_perm = 'repository.admin'
1722 1722 owner_row = []
1723 1723 if with_owner:
1724 1724 usr = AttributeDict(self.user.get_dict())
1725 1725 usr.owner_row = True
1726 1726 usr.permission = _admin_perm
1727 1727 owner_row.append(usr)
1728 1728
1729 1729 super_admin_rows = []
1730 1730 if with_admins:
1731 1731 for usr in User.get_all_super_admins():
1732 1732 # if this admin is also owner, don't double the record
1733 1733 if usr.user_id == owner_row[0].user_id:
1734 1734 owner_row[0].admin_row = True
1735 1735 else:
1736 1736 usr = AttributeDict(usr.get_dict())
1737 1737 usr.admin_row = True
1738 1738 usr.permission = _admin_perm
1739 1739 super_admin_rows.append(usr)
1740 1740
1741 1741 return super_admin_rows + owner_row + perm_rows
1742 1742
1743 1743 def permission_user_groups(self):
1744 1744 q = UserGroupRepoToPerm.query().filter(
1745 1745 UserGroupRepoToPerm.repository == self)
1746 1746 q = q.options(joinedload(UserGroupRepoToPerm.repository),
1747 1747 joinedload(UserGroupRepoToPerm.users_group),
1748 1748 joinedload(UserGroupRepoToPerm.permission),)
1749 1749
1750 1750 perm_rows = []
1751 1751 for _user_group in q.all():
1752 1752 usr = AttributeDict(_user_group.users_group.get_dict())
1753 1753 usr.permission = _user_group.permission.permission_name
1754 1754 perm_rows.append(usr)
1755 1755
1756 1756 return perm_rows
1757 1757
1758 1758 def get_api_data(self, include_secrets=False):
1759 1759 """
1760 1760 Common function for generating repo api data
1761 1761
1762 1762 :param include_secrets: See :meth:`User.get_api_data`.
1763 1763
1764 1764 """
1765 1765 # TODO: mikhail: Here there is an anti-pattern, we probably need to
1766 1766 # move this methods on models level.
1767 1767 from rhodecode.model.settings import SettingsModel
1768 1768 from rhodecode.model.repo import RepoModel
1769 1769
1770 1770 repo = self
1771 1771 _user_id, _time, _reason = self.locked
1772 1772
1773 1773 data = {
1774 1774 'repo_id': repo.repo_id,
1775 1775 'repo_name': repo.repo_name,
1776 1776 'repo_type': repo.repo_type,
1777 1777 'clone_uri': repo.clone_uri or '',
1778 1778 'url': RepoModel().get_url(self),
1779 1779 'private': repo.private,
1780 1780 'created_on': repo.created_on,
1781 1781 'description': repo.description,
1782 1782 'landing_rev': repo.landing_rev,
1783 1783 'owner': repo.user.username,
1784 1784 'fork_of': repo.fork.repo_name if repo.fork else None,
1785 1785 'enable_statistics': repo.enable_statistics,
1786 1786 'enable_locking': repo.enable_locking,
1787 1787 'enable_downloads': repo.enable_downloads,
1788 1788 'last_changeset': repo.changeset_cache,
1789 1789 'locked_by': User.get(_user_id).get_api_data(
1790 1790 include_secrets=include_secrets) if _user_id else None,
1791 1791 'locked_date': time_to_datetime(_time) if _time else None,
1792 1792 'lock_reason': _reason if _reason else None,
1793 1793 }
1794 1794
1795 1795 # TODO: mikhail: should be per-repo settings here
1796 1796 rc_config = SettingsModel().get_all_settings()
1797 1797 repository_fields = str2bool(
1798 1798 rc_config.get('rhodecode_repository_fields'))
1799 1799 if repository_fields:
1800 1800 for f in self.extra_fields:
1801 1801 data[f.field_key_prefixed] = f.field_value
1802 1802
1803 1803 return data
1804 1804
1805 1805 @classmethod
1806 1806 def lock(cls, repo, user_id, lock_time=None, lock_reason=None):
1807 1807 if not lock_time:
1808 1808 lock_time = time.time()
1809 1809 if not lock_reason:
1810 1810 lock_reason = cls.LOCK_AUTOMATIC
1811 1811 repo.locked = [user_id, lock_time, lock_reason]
1812 1812 Session().add(repo)
1813 1813 Session().commit()
1814 1814
1815 1815 @classmethod
1816 1816 def unlock(cls, repo):
1817 1817 repo.locked = None
1818 1818 Session().add(repo)
1819 1819 Session().commit()
1820 1820
1821 1821 @classmethod
1822 1822 def getlock(cls, repo):
1823 1823 return repo.locked
1824 1824
1825 1825 def is_user_lock(self, user_id):
1826 1826 if self.lock[0]:
1827 1827 lock_user_id = safe_int(self.lock[0])
1828 1828 user_id = safe_int(user_id)
1829 1829 # both are ints, and they are equal
1830 1830 return all([lock_user_id, user_id]) and lock_user_id == user_id
1831 1831
1832 1832 return False
1833 1833
1834 1834 def get_locking_state(self, action, user_id, only_when_enabled=True):
1835 1835 """
1836 1836 Checks locking on this repository, if locking is enabled and lock is
1837 1837 present returns a tuple of make_lock, locked, locked_by.
1838 1838 make_lock can have 3 states None (do nothing) True, make lock
1839 1839 False release lock, This value is later propagated to hooks, which
1840 1840 do the locking. Think about this as signals passed to hooks what to do.
1841 1841
1842 1842 """
1843 1843 # TODO: johbo: This is part of the business logic and should be moved
1844 1844 # into the RepositoryModel.
1845 1845
1846 1846 if action not in ('push', 'pull'):
1847 1847 raise ValueError("Invalid action value: %s" % repr(action))
1848 1848
1849 1849 # defines if locked error should be thrown to user
1850 1850 currently_locked = False
1851 1851 # defines if new lock should be made, tri-state
1852 1852 make_lock = None
1853 1853 repo = self
1854 1854 user = User.get(user_id)
1855 1855
1856 1856 lock_info = repo.locked
1857 1857
1858 1858 if repo and (repo.enable_locking or not only_when_enabled):
1859 1859 if action == 'push':
1860 1860 # check if it's already locked !, if it is compare users
1861 1861 locked_by_user_id = lock_info[0]
1862 1862 if user.user_id == locked_by_user_id:
1863 1863 log.debug(
1864 1864 'Got `push` action from user %s, now unlocking', user)
1865 1865 # unlock if we have push from user who locked
1866 1866 make_lock = False
1867 1867 else:
1868 1868 # we're not the same user who locked, ban with
1869 1869 # code defined in settings (default is 423 HTTP Locked) !
1870 1870 log.debug('Repo %s is currently locked by %s', repo, user)
1871 1871 currently_locked = True
1872 1872 elif action == 'pull':
1873 1873 # [0] user [1] date
1874 1874 if lock_info[0] and lock_info[1]:
1875 1875 log.debug('Repo %s is currently locked by %s', repo, user)
1876 1876 currently_locked = True
1877 1877 else:
1878 1878 log.debug('Setting lock on repo %s by %s', repo, user)
1879 1879 make_lock = True
1880 1880
1881 1881 else:
1882 1882 log.debug('Repository %s do not have locking enabled', repo)
1883 1883
1884 1884 log.debug('FINAL locking values make_lock:%s,locked:%s,locked_by:%s',
1885 1885 make_lock, currently_locked, lock_info)
1886 1886
1887 1887 from rhodecode.lib.auth import HasRepoPermissionAny
1888 1888 perm_check = HasRepoPermissionAny('repository.write', 'repository.admin')
1889 1889 if make_lock and not perm_check(repo_name=repo.repo_name, user=user):
1890 1890 # if we don't have at least write permission we cannot make a lock
1891 1891 log.debug('lock state reset back to FALSE due to lack '
1892 1892 'of at least read permission')
1893 1893 make_lock = False
1894 1894
1895 1895 return make_lock, currently_locked, lock_info
1896 1896
1897 1897 @property
1898 1898 def last_db_change(self):
1899 1899 return self.updated_on
1900 1900
1901 1901 @property
1902 1902 def clone_uri_hidden(self):
1903 1903 clone_uri = self.clone_uri
1904 1904 if clone_uri:
1905 1905 import urlobject
1906 1906 url_obj = urlobject.URLObject(cleaned_uri(clone_uri))
1907 1907 if url_obj.password:
1908 1908 clone_uri = url_obj.with_password('*****')
1909 1909 return clone_uri
1910 1910
1911 1911 def clone_url(self, **override):
1912 1912
1913 1913 uri_tmpl = None
1914 1914 if 'with_id' in override:
1915 1915 uri_tmpl = self.DEFAULT_CLONE_URI_ID
1916 1916 del override['with_id']
1917 1917
1918 1918 if 'uri_tmpl' in override:
1919 1919 uri_tmpl = override['uri_tmpl']
1920 1920 del override['uri_tmpl']
1921 1921
1922 1922 # we didn't override our tmpl from **overrides
1923 1923 if not uri_tmpl:
1924 1924 uri_tmpl = self.DEFAULT_CLONE_URI
1925 1925 try:
1926 1926 from pylons import tmpl_context as c
1927 1927 uri_tmpl = c.clone_uri_tmpl
1928 1928 except Exception:
1929 1929 # in any case if we call this outside of request context,
1930 1930 # ie, not having tmpl_context set up
1931 1931 pass
1932 1932
1933 1933 request = get_current_request()
1934 1934 return get_clone_url(request=request,
1935 1935 uri_tmpl=uri_tmpl,
1936 1936 repo_name=self.repo_name,
1937 1937 repo_id=self.repo_id, **override)
1938 1938
1939 1939 def set_state(self, state):
1940 1940 self.repo_state = state
1941 1941 Session().add(self)
1942 1942 #==========================================================================
1943 1943 # SCM PROPERTIES
1944 1944 #==========================================================================
1945 1945
1946 1946 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None):
1947 1947 return get_commit_safe(
1948 1948 self.scm_instance(), commit_id, commit_idx, pre_load=pre_load)
1949 1949
1950 1950 def get_changeset(self, rev=None, pre_load=None):
1951 1951 warnings.warn("Use get_commit", DeprecationWarning)
1952 1952 commit_id = None
1953 1953 commit_idx = None
1954 1954 if isinstance(rev, basestring):
1955 1955 commit_id = rev
1956 1956 else:
1957 1957 commit_idx = rev
1958 1958 return self.get_commit(commit_id=commit_id, commit_idx=commit_idx,
1959 1959 pre_load=pre_load)
1960 1960
1961 1961 def get_landing_commit(self):
1962 1962 """
1963 1963 Returns landing commit, or if that doesn't exist returns the tip
1964 1964 """
1965 1965 _rev_type, _rev = self.landing_rev
1966 1966 commit = self.get_commit(_rev)
1967 1967 if isinstance(commit, EmptyCommit):
1968 1968 return self.get_commit()
1969 1969 return commit
1970 1970
1971 1971 def update_commit_cache(self, cs_cache=None, config=None):
1972 1972 """
1973 1973 Update cache of last changeset for repository, keys should be::
1974 1974
1975 1975 short_id
1976 1976 raw_id
1977 1977 revision
1978 1978 parents
1979 1979 message
1980 1980 date
1981 1981 author
1982 1982
1983 1983 :param cs_cache:
1984 1984 """
1985 1985 from rhodecode.lib.vcs.backends.base import BaseChangeset
1986 1986 if cs_cache is None:
1987 1987 # use no-cache version here
1988 1988 scm_repo = self.scm_instance(cache=False, config=config)
1989 1989 if scm_repo:
1990 1990 cs_cache = scm_repo.get_commit(
1991 1991 pre_load=["author", "date", "message", "parents"])
1992 1992 else:
1993 1993 cs_cache = EmptyCommit()
1994 1994
1995 1995 if isinstance(cs_cache, BaseChangeset):
1996 1996 cs_cache = cs_cache.__json__()
1997 1997
1998 1998 def is_outdated(new_cs_cache):
1999 1999 if (new_cs_cache['raw_id'] != self.changeset_cache['raw_id'] or
2000 2000 new_cs_cache['revision'] != self.changeset_cache['revision']):
2001 2001 return True
2002 2002 return False
2003 2003
2004 2004 # check if we have maybe already latest cached revision
2005 2005 if is_outdated(cs_cache) or not self.changeset_cache:
2006 2006 _default = datetime.datetime.fromtimestamp(0)
2007 2007 last_change = cs_cache.get('date') or _default
2008 2008 log.debug('updated repo %s with new cs cache %s',
2009 2009 self.repo_name, cs_cache)
2010 2010 self.updated_on = last_change
2011 2011 self.changeset_cache = cs_cache
2012 2012 Session().add(self)
2013 2013 Session().commit()
2014 2014 else:
2015 2015 log.debug('Skipping update_commit_cache for repo:`%s` '
2016 2016 'commit already with latest changes', self.repo_name)
2017 2017
2018 2018 @property
2019 2019 def tip(self):
2020 2020 return self.get_commit('tip')
2021 2021
2022 2022 @property
2023 2023 def author(self):
2024 2024 return self.tip.author
2025 2025
2026 2026 @property
2027 2027 def last_change(self):
2028 2028 return self.scm_instance().last_change
2029 2029
2030 2030 def get_comments(self, revisions=None):
2031 2031 """
2032 2032 Returns comments for this repository grouped by revisions
2033 2033
2034 2034 :param revisions: filter query by revisions only
2035 2035 """
2036 2036 cmts = ChangesetComment.query()\
2037 2037 .filter(ChangesetComment.repo == self)
2038 2038 if revisions:
2039 2039 cmts = cmts.filter(ChangesetComment.revision.in_(revisions))
2040 2040 grouped = collections.defaultdict(list)
2041 2041 for cmt in cmts.all():
2042 2042 grouped[cmt.revision].append(cmt)
2043 2043 return grouped
2044 2044
2045 2045 def statuses(self, revisions=None):
2046 2046 """
2047 2047 Returns statuses for this repository
2048 2048
2049 2049 :param revisions: list of revisions to get statuses for
2050 2050 """
2051 2051 statuses = ChangesetStatus.query()\
2052 2052 .filter(ChangesetStatus.repo == self)\
2053 2053 .filter(ChangesetStatus.version == 0)
2054 2054
2055 2055 if revisions:
2056 2056 # Try doing the filtering in chunks to avoid hitting limits
2057 2057 size = 500
2058 2058 status_results = []
2059 2059 for chunk in xrange(0, len(revisions), size):
2060 2060 status_results += statuses.filter(
2061 2061 ChangesetStatus.revision.in_(
2062 2062 revisions[chunk: chunk+size])
2063 2063 ).all()
2064 2064 else:
2065 2065 status_results = statuses.all()
2066 2066
2067 2067 grouped = {}
2068 2068
2069 2069 # maybe we have open new pullrequest without a status?
2070 2070 stat = ChangesetStatus.STATUS_UNDER_REVIEW
2071 2071 status_lbl = ChangesetStatus.get_status_lbl(stat)
2072 2072 for pr in PullRequest.query().filter(PullRequest.source_repo == self).all():
2073 2073 for rev in pr.revisions:
2074 2074 pr_id = pr.pull_request_id
2075 2075 pr_repo = pr.target_repo.repo_name
2076 2076 grouped[rev] = [stat, status_lbl, pr_id, pr_repo]
2077 2077
2078 2078 for stat in status_results:
2079 2079 pr_id = pr_repo = None
2080 2080 if stat.pull_request:
2081 2081 pr_id = stat.pull_request.pull_request_id
2082 2082 pr_repo = stat.pull_request.target_repo.repo_name
2083 2083 grouped[stat.revision] = [str(stat.status), stat.status_lbl,
2084 2084 pr_id, pr_repo]
2085 2085 return grouped
2086 2086
2087 2087 # ==========================================================================
2088 2088 # SCM CACHE INSTANCE
2089 2089 # ==========================================================================
2090 2090
2091 2091 def scm_instance(self, **kwargs):
2092 2092 import rhodecode
2093 2093
2094 2094 # Passing a config will not hit the cache currently only used
2095 2095 # for repo2dbmapper
2096 2096 config = kwargs.pop('config', None)
2097 2097 cache = kwargs.pop('cache', None)
2098 2098 full_cache = str2bool(rhodecode.CONFIG.get('vcs_full_cache'))
2099 2099 # if cache is NOT defined use default global, else we have a full
2100 2100 # control over cache behaviour
2101 2101 if cache is None and full_cache and not config:
2102 2102 return self._get_instance_cached()
2103 2103 return self._get_instance(cache=bool(cache), config=config)
2104 2104
2105 2105 def _get_instance_cached(self):
2106 2106 @cache_region('long_term')
2107 2107 def _get_repo(cache_key):
2108 2108 return self._get_instance()
2109 2109
2110 2110 invalidator_context = CacheKey.repo_context_cache(
2111 2111 _get_repo, self.repo_name, None, thread_scoped=True)
2112 2112
2113 2113 with invalidator_context as context:
2114 2114 context.invalidate()
2115 2115 repo = context.compute()
2116 2116
2117 2117 return repo
2118 2118
2119 2119 def _get_instance(self, cache=True, config=None):
2120 2120 config = config or self._config
2121 2121 custom_wire = {
2122 2122 'cache': cache # controls the vcs.remote cache
2123 2123 }
2124 2124 repo = get_vcs_instance(
2125 2125 repo_path=safe_str(self.repo_full_path),
2126 2126 config=config,
2127 2127 with_wire=custom_wire,
2128 2128 create=False,
2129 2129 _vcs_alias=self.repo_type)
2130 2130
2131 2131 return repo
2132 2132
2133 2133 def __json__(self):
2134 2134 return {'landing_rev': self.landing_rev}
2135 2135
2136 2136 def get_dict(self):
2137 2137
2138 2138 # Since we transformed `repo_name` to a hybrid property, we need to
2139 2139 # keep compatibility with the code which uses `repo_name` field.
2140 2140
2141 2141 result = super(Repository, self).get_dict()
2142 2142 result['repo_name'] = result.pop('_repo_name', None)
2143 2143 return result
2144 2144
2145 2145
2146 2146 class RepoGroup(Base, BaseModel):
2147 2147 __tablename__ = 'groups'
2148 2148 __table_args__ = (
2149 2149 UniqueConstraint('group_name', 'group_parent_id'),
2150 2150 CheckConstraint('group_id != group_parent_id'),
2151 2151 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2152 2152 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
2153 2153 )
2154 2154 __mapper_args__ = {'order_by': 'group_name'}
2155 2155
2156 2156 CHOICES_SEPARATOR = '/' # used to generate select2 choices for nested groups
2157 2157
2158 2158 group_id = Column("group_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2159 2159 group_name = Column("group_name", String(255), nullable=False, unique=True, default=None)
2160 2160 group_parent_id = Column("group_parent_id", Integer(), ForeignKey('groups.group_id'), nullable=True, unique=None, default=None)
2161 2161 group_description = Column("group_description", String(10000), nullable=True, unique=None, default=None)
2162 2162 enable_locking = Column("enable_locking", Boolean(), nullable=False, unique=None, default=False)
2163 2163 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=False, default=None)
2164 2164 created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
2165 2165 personal = Column('personal', Boolean(), nullable=True, unique=None, default=None)
2166 2166
2167 2167 repo_group_to_perm = relationship('UserRepoGroupToPerm', cascade='all', order_by='UserRepoGroupToPerm.group_to_perm_id')
2168 2168 users_group_to_perm = relationship('UserGroupRepoGroupToPerm', cascade='all')
2169 2169 parent_group = relationship('RepoGroup', remote_side=group_id)
2170 2170 user = relationship('User')
2171 2171 integrations = relationship('Integration',
2172 2172 cascade="all, delete, delete-orphan")
2173 2173
2174 2174 def __init__(self, group_name='', parent_group=None):
2175 2175 self.group_name = group_name
2176 2176 self.parent_group = parent_group
2177 2177
2178 2178 def __unicode__(self):
2179 2179 return u"<%s('id:%s:%s')>" % (self.__class__.__name__, self.group_id,
2180 2180 self.group_name)
2181 2181
2182 2182 @classmethod
2183 2183 def _generate_choice(cls, repo_group):
2184 2184 from webhelpers.html import literal as _literal
2185 2185 _name = lambda k: _literal(cls.CHOICES_SEPARATOR.join(k))
2186 2186 return repo_group.group_id, _name(repo_group.full_path_splitted)
2187 2187
2188 2188 @classmethod
2189 2189 def groups_choices(cls, groups=None, show_empty_group=True):
2190 2190 if not groups:
2191 2191 groups = cls.query().all()
2192 2192
2193 2193 repo_groups = []
2194 2194 if show_empty_group:
2195 2195 repo_groups = [(-1, u'-- %s --' % _('No parent'))]
2196 2196
2197 2197 repo_groups.extend([cls._generate_choice(x) for x in groups])
2198 2198
2199 2199 repo_groups = sorted(
2200 2200 repo_groups, key=lambda t: t[1].split(cls.CHOICES_SEPARATOR)[0])
2201 2201 return repo_groups
2202 2202
2203 2203 @classmethod
2204 2204 def url_sep(cls):
2205 2205 return URL_SEP
2206 2206
2207 2207 @classmethod
2208 2208 def get_by_group_name(cls, group_name, cache=False, case_insensitive=False):
2209 2209 if case_insensitive:
2210 2210 gr = cls.query().filter(func.lower(cls.group_name)
2211 2211 == func.lower(group_name))
2212 2212 else:
2213 2213 gr = cls.query().filter(cls.group_name == group_name)
2214 2214 if cache:
2215 2215 name_key = _hash_key(group_name)
2216 2216 gr = gr.options(
2217 2217 FromCache("sql_cache_short", "get_group_%s" % name_key))
2218 2218 return gr.scalar()
2219 2219
2220 2220 @classmethod
2221 2221 def get_user_personal_repo_group(cls, user_id):
2222 2222 user = User.get(user_id)
2223 2223 if user.username == User.DEFAULT_USER:
2224 2224 return None
2225 2225
2226 2226 return cls.query()\
2227 2227 .filter(cls.personal == true()) \
2228 2228 .filter(cls.user == user).scalar()
2229 2229
2230 2230 @classmethod
2231 2231 def get_all_repo_groups(cls, user_id=Optional(None), group_id=Optional(None),
2232 2232 case_insensitive=True):
2233 2233 q = RepoGroup.query()
2234 2234
2235 2235 if not isinstance(user_id, Optional):
2236 2236 q = q.filter(RepoGroup.user_id == user_id)
2237 2237
2238 2238 if not isinstance(group_id, Optional):
2239 2239 q = q.filter(RepoGroup.group_parent_id == group_id)
2240 2240
2241 2241 if case_insensitive:
2242 2242 q = q.order_by(func.lower(RepoGroup.group_name))
2243 2243 else:
2244 2244 q = q.order_by(RepoGroup.group_name)
2245 2245 return q.all()
2246 2246
2247 2247 @property
2248 2248 def parents(self):
2249 2249 parents_recursion_limit = 10
2250 2250 groups = []
2251 2251 if self.parent_group is None:
2252 2252 return groups
2253 2253 cur_gr = self.parent_group
2254 2254 groups.insert(0, cur_gr)
2255 2255 cnt = 0
2256 2256 while 1:
2257 2257 cnt += 1
2258 2258 gr = getattr(cur_gr, 'parent_group', None)
2259 2259 cur_gr = cur_gr.parent_group
2260 2260 if gr is None:
2261 2261 break
2262 2262 if cnt == parents_recursion_limit:
2263 2263 # this will prevent accidental infinit loops
2264 2264 log.error(('more than %s parents found for group %s, stopping '
2265 2265 'recursive parent fetching' % (parents_recursion_limit, self)))
2266 2266 break
2267 2267
2268 2268 groups.insert(0, gr)
2269 2269 return groups
2270 2270
2271 2271 @property
2272 2272 def children(self):
2273 2273 return RepoGroup.query().filter(RepoGroup.parent_group == self)
2274 2274
2275 2275 @property
2276 2276 def name(self):
2277 2277 return self.group_name.split(RepoGroup.url_sep())[-1]
2278 2278
2279 2279 @property
2280 2280 def full_path(self):
2281 2281 return self.group_name
2282 2282
2283 2283 @property
2284 2284 def full_path_splitted(self):
2285 2285 return self.group_name.split(RepoGroup.url_sep())
2286 2286
2287 2287 @property
2288 2288 def repositories(self):
2289 2289 return Repository.query()\
2290 2290 .filter(Repository.group == self)\
2291 2291 .order_by(Repository.repo_name)
2292 2292
2293 2293 @property
2294 2294 def repositories_recursive_count(self):
2295 2295 cnt = self.repositories.count()
2296 2296
2297 2297 def children_count(group):
2298 2298 cnt = 0
2299 2299 for child in group.children:
2300 2300 cnt += child.repositories.count()
2301 2301 cnt += children_count(child)
2302 2302 return cnt
2303 2303
2304 2304 return cnt + children_count(self)
2305 2305
2306 2306 def _recursive_objects(self, include_repos=True):
2307 2307 all_ = []
2308 2308
2309 2309 def _get_members(root_gr):
2310 2310 if include_repos:
2311 2311 for r in root_gr.repositories:
2312 2312 all_.append(r)
2313 2313 childs = root_gr.children.all()
2314 2314 if childs:
2315 2315 for gr in childs:
2316 2316 all_.append(gr)
2317 2317 _get_members(gr)
2318 2318
2319 2319 _get_members(self)
2320 2320 return [self] + all_
2321 2321
2322 2322 def recursive_groups_and_repos(self):
2323 2323 """
2324 2324 Recursive return all groups, with repositories in those groups
2325 2325 """
2326 2326 return self._recursive_objects()
2327 2327
2328 2328 def recursive_groups(self):
2329 2329 """
2330 2330 Returns all children groups for this group including children of children
2331 2331 """
2332 2332 return self._recursive_objects(include_repos=False)
2333 2333
2334 2334 def get_new_name(self, group_name):
2335 2335 """
2336 2336 returns new full group name based on parent and new name
2337 2337
2338 2338 :param group_name:
2339 2339 """
2340 2340 path_prefix = (self.parent_group.full_path_splitted if
2341 2341 self.parent_group else [])
2342 2342 return RepoGroup.url_sep().join(path_prefix + [group_name])
2343 2343
2344 2344 def permissions(self, with_admins=True, with_owner=True):
2345 2345 q = UserRepoGroupToPerm.query().filter(UserRepoGroupToPerm.group == self)
2346 2346 q = q.options(joinedload(UserRepoGroupToPerm.group),
2347 2347 joinedload(UserRepoGroupToPerm.user),
2348 2348 joinedload(UserRepoGroupToPerm.permission),)
2349 2349
2350 2350 # get owners and admins and permissions. We do a trick of re-writing
2351 2351 # objects from sqlalchemy to named-tuples due to sqlalchemy session
2352 2352 # has a global reference and changing one object propagates to all
2353 2353 # others. This means if admin is also an owner admin_row that change
2354 2354 # would propagate to both objects
2355 2355 perm_rows = []
2356 2356 for _usr in q.all():
2357 2357 usr = AttributeDict(_usr.user.get_dict())
2358 2358 usr.permission = _usr.permission.permission_name
2359 2359 perm_rows.append(usr)
2360 2360
2361 2361 # filter the perm rows by 'default' first and then sort them by
2362 2362 # admin,write,read,none permissions sorted again alphabetically in
2363 2363 # each group
2364 2364 perm_rows = sorted(perm_rows, key=display_sort)
2365 2365
2366 2366 _admin_perm = 'group.admin'
2367 2367 owner_row = []
2368 2368 if with_owner:
2369 2369 usr = AttributeDict(self.user.get_dict())
2370 2370 usr.owner_row = True
2371 2371 usr.permission = _admin_perm
2372 2372 owner_row.append(usr)
2373 2373
2374 2374 super_admin_rows = []
2375 2375 if with_admins:
2376 2376 for usr in User.get_all_super_admins():
2377 2377 # if this admin is also owner, don't double the record
2378 2378 if usr.user_id == owner_row[0].user_id:
2379 2379 owner_row[0].admin_row = True
2380 2380 else:
2381 2381 usr = AttributeDict(usr.get_dict())
2382 2382 usr.admin_row = True
2383 2383 usr.permission = _admin_perm
2384 2384 super_admin_rows.append(usr)
2385 2385
2386 2386 return super_admin_rows + owner_row + perm_rows
2387 2387
2388 2388 def permission_user_groups(self):
2389 2389 q = UserGroupRepoGroupToPerm.query().filter(UserGroupRepoGroupToPerm.group == self)
2390 2390 q = q.options(joinedload(UserGroupRepoGroupToPerm.group),
2391 2391 joinedload(UserGroupRepoGroupToPerm.users_group),
2392 2392 joinedload(UserGroupRepoGroupToPerm.permission),)
2393 2393
2394 2394 perm_rows = []
2395 2395 for _user_group in q.all():
2396 2396 usr = AttributeDict(_user_group.users_group.get_dict())
2397 2397 usr.permission = _user_group.permission.permission_name
2398 2398 perm_rows.append(usr)
2399 2399
2400 2400 return perm_rows
2401 2401
2402 2402 def get_api_data(self):
2403 2403 """
2404 2404 Common function for generating api data
2405 2405
2406 2406 """
2407 2407 group = self
2408 2408 data = {
2409 2409 'group_id': group.group_id,
2410 2410 'group_name': group.group_name,
2411 2411 'group_description': group.group_description,
2412 2412 'parent_group': group.parent_group.group_name if group.parent_group else None,
2413 2413 'repositories': [x.repo_name for x in group.repositories],
2414 2414 'owner': group.user.username,
2415 2415 }
2416 2416 return data
2417 2417
2418 2418
2419 2419 class Permission(Base, BaseModel):
2420 2420 __tablename__ = 'permissions'
2421 2421 __table_args__ = (
2422 2422 Index('p_perm_name_idx', 'permission_name'),
2423 2423 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2424 2424 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
2425 2425 )
2426 2426 PERMS = [
2427 2427 ('hg.admin', _('RhodeCode Super Administrator')),
2428 2428
2429 2429 ('repository.none', _('Repository no access')),
2430 2430 ('repository.read', _('Repository read access')),
2431 2431 ('repository.write', _('Repository write access')),
2432 2432 ('repository.admin', _('Repository admin access')),
2433 2433
2434 2434 ('group.none', _('Repository group no access')),
2435 2435 ('group.read', _('Repository group read access')),
2436 2436 ('group.write', _('Repository group write access')),
2437 2437 ('group.admin', _('Repository group admin access')),
2438 2438
2439 2439 ('usergroup.none', _('User group no access')),
2440 2440 ('usergroup.read', _('User group read access')),
2441 2441 ('usergroup.write', _('User group write access')),
2442 2442 ('usergroup.admin', _('User group admin access')),
2443 2443
2444 2444 ('hg.repogroup.create.false', _('Repository Group creation disabled')),
2445 2445 ('hg.repogroup.create.true', _('Repository Group creation enabled')),
2446 2446
2447 2447 ('hg.usergroup.create.false', _('User Group creation disabled')),
2448 2448 ('hg.usergroup.create.true', _('User Group creation enabled')),
2449 2449
2450 2450 ('hg.create.none', _('Repository creation disabled')),
2451 2451 ('hg.create.repository', _('Repository creation enabled')),
2452 2452 ('hg.create.write_on_repogroup.true', _('Repository creation enabled with write permission to a repository group')),
2453 2453 ('hg.create.write_on_repogroup.false', _('Repository creation disabled with write permission to a repository group')),
2454 2454
2455 2455 ('hg.fork.none', _('Repository forking disabled')),
2456 2456 ('hg.fork.repository', _('Repository forking enabled')),
2457 2457
2458 2458 ('hg.register.none', _('Registration disabled')),
2459 2459 ('hg.register.manual_activate', _('User Registration with manual account activation')),
2460 2460 ('hg.register.auto_activate', _('User Registration with automatic account activation')),
2461 2461
2462 2462 ('hg.password_reset.enabled', _('Password reset enabled')),
2463 2463 ('hg.password_reset.hidden', _('Password reset hidden')),
2464 2464 ('hg.password_reset.disabled', _('Password reset disabled')),
2465 2465
2466 2466 ('hg.extern_activate.manual', _('Manual activation of external account')),
2467 2467 ('hg.extern_activate.auto', _('Automatic activation of external account')),
2468 2468
2469 2469 ('hg.inherit_default_perms.false', _('Inherit object permissions from default user disabled')),
2470 2470 ('hg.inherit_default_perms.true', _('Inherit object permissions from default user enabled')),
2471 2471 ]
2472 2472
2473 2473 # definition of system default permissions for DEFAULT user
2474 2474 DEFAULT_USER_PERMISSIONS = [
2475 2475 'repository.read',
2476 2476 'group.read',
2477 2477 'usergroup.read',
2478 2478 'hg.create.repository',
2479 2479 'hg.repogroup.create.false',
2480 2480 'hg.usergroup.create.false',
2481 2481 'hg.create.write_on_repogroup.true',
2482 2482 'hg.fork.repository',
2483 2483 'hg.register.manual_activate',
2484 2484 'hg.password_reset.enabled',
2485 2485 'hg.extern_activate.auto',
2486 2486 'hg.inherit_default_perms.true',
2487 2487 ]
2488 2488
2489 2489 # defines which permissions are more important higher the more important
2490 2490 # Weight defines which permissions are more important.
2491 2491 # The higher number the more important.
2492 2492 PERM_WEIGHTS = {
2493 2493 'repository.none': 0,
2494 2494 'repository.read': 1,
2495 2495 'repository.write': 3,
2496 2496 'repository.admin': 4,
2497 2497
2498 2498 'group.none': 0,
2499 2499 'group.read': 1,
2500 2500 'group.write': 3,
2501 2501 'group.admin': 4,
2502 2502
2503 2503 'usergroup.none': 0,
2504 2504 'usergroup.read': 1,
2505 2505 'usergroup.write': 3,
2506 2506 'usergroup.admin': 4,
2507 2507
2508 2508 'hg.repogroup.create.false': 0,
2509 2509 'hg.repogroup.create.true': 1,
2510 2510
2511 2511 'hg.usergroup.create.false': 0,
2512 2512 'hg.usergroup.create.true': 1,
2513 2513
2514 2514 'hg.fork.none': 0,
2515 2515 'hg.fork.repository': 1,
2516 2516 'hg.create.none': 0,
2517 2517 'hg.create.repository': 1
2518 2518 }
2519 2519
2520 2520 permission_id = Column("permission_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2521 2521 permission_name = Column("permission_name", String(255), nullable=True, unique=None, default=None)
2522 2522 permission_longname = Column("permission_longname", String(255), nullable=True, unique=None, default=None)
2523 2523
2524 2524 def __unicode__(self):
2525 2525 return u"<%s('%s:%s')>" % (
2526 2526 self.__class__.__name__, self.permission_id, self.permission_name
2527 2527 )
2528 2528
2529 2529 @classmethod
2530 2530 def get_by_key(cls, key):
2531 2531 return cls.query().filter(cls.permission_name == key).scalar()
2532 2532
2533 2533 @classmethod
2534 2534 def get_default_repo_perms(cls, user_id, repo_id=None):
2535 2535 q = Session().query(UserRepoToPerm, Repository, Permission)\
2536 2536 .join((Permission, UserRepoToPerm.permission_id == Permission.permission_id))\
2537 2537 .join((Repository, UserRepoToPerm.repository_id == Repository.repo_id))\
2538 2538 .filter(UserRepoToPerm.user_id == user_id)
2539 2539 if repo_id:
2540 2540 q = q.filter(UserRepoToPerm.repository_id == repo_id)
2541 2541 return q.all()
2542 2542
2543 2543 @classmethod
2544 2544 def get_default_repo_perms_from_user_group(cls, user_id, repo_id=None):
2545 2545 q = Session().query(UserGroupRepoToPerm, Repository, Permission)\
2546 2546 .join(
2547 2547 Permission,
2548 2548 UserGroupRepoToPerm.permission_id == Permission.permission_id)\
2549 2549 .join(
2550 2550 Repository,
2551 2551 UserGroupRepoToPerm.repository_id == Repository.repo_id)\
2552 2552 .join(
2553 2553 UserGroup,
2554 2554 UserGroupRepoToPerm.users_group_id ==
2555 2555 UserGroup.users_group_id)\
2556 2556 .join(
2557 2557 UserGroupMember,
2558 2558 UserGroupRepoToPerm.users_group_id ==
2559 2559 UserGroupMember.users_group_id)\
2560 2560 .filter(
2561 2561 UserGroupMember.user_id == user_id,
2562 2562 UserGroup.users_group_active == true())
2563 2563 if repo_id:
2564 2564 q = q.filter(UserGroupRepoToPerm.repository_id == repo_id)
2565 2565 return q.all()
2566 2566
2567 2567 @classmethod
2568 2568 def get_default_group_perms(cls, user_id, repo_group_id=None):
2569 2569 q = Session().query(UserRepoGroupToPerm, RepoGroup, Permission)\
2570 2570 .join((Permission, UserRepoGroupToPerm.permission_id == Permission.permission_id))\
2571 2571 .join((RepoGroup, UserRepoGroupToPerm.group_id == RepoGroup.group_id))\
2572 2572 .filter(UserRepoGroupToPerm.user_id == user_id)
2573 2573 if repo_group_id:
2574 2574 q = q.filter(UserRepoGroupToPerm.group_id == repo_group_id)
2575 2575 return q.all()
2576 2576
2577 2577 @classmethod
2578 2578 def get_default_group_perms_from_user_group(
2579 2579 cls, user_id, repo_group_id=None):
2580 2580 q = Session().query(UserGroupRepoGroupToPerm, RepoGroup, Permission)\
2581 2581 .join(
2582 2582 Permission,
2583 2583 UserGroupRepoGroupToPerm.permission_id ==
2584 2584 Permission.permission_id)\
2585 2585 .join(
2586 2586 RepoGroup,
2587 2587 UserGroupRepoGroupToPerm.group_id == RepoGroup.group_id)\
2588 2588 .join(
2589 2589 UserGroup,
2590 2590 UserGroupRepoGroupToPerm.users_group_id ==
2591 2591 UserGroup.users_group_id)\
2592 2592 .join(
2593 2593 UserGroupMember,
2594 2594 UserGroupRepoGroupToPerm.users_group_id ==
2595 2595 UserGroupMember.users_group_id)\
2596 2596 .filter(
2597 2597 UserGroupMember.user_id == user_id,
2598 2598 UserGroup.users_group_active == true())
2599 2599 if repo_group_id:
2600 2600 q = q.filter(UserGroupRepoGroupToPerm.group_id == repo_group_id)
2601 2601 return q.all()
2602 2602
2603 2603 @classmethod
2604 2604 def get_default_user_group_perms(cls, user_id, user_group_id=None):
2605 2605 q = Session().query(UserUserGroupToPerm, UserGroup, Permission)\
2606 2606 .join((Permission, UserUserGroupToPerm.permission_id == Permission.permission_id))\
2607 2607 .join((UserGroup, UserUserGroupToPerm.user_group_id == UserGroup.users_group_id))\
2608 2608 .filter(UserUserGroupToPerm.user_id == user_id)
2609 2609 if user_group_id:
2610 2610 q = q.filter(UserUserGroupToPerm.user_group_id == user_group_id)
2611 2611 return q.all()
2612 2612
2613 2613 @classmethod
2614 2614 def get_default_user_group_perms_from_user_group(
2615 2615 cls, user_id, user_group_id=None):
2616 2616 TargetUserGroup = aliased(UserGroup, name='target_user_group')
2617 2617 q = Session().query(UserGroupUserGroupToPerm, UserGroup, Permission)\
2618 2618 .join(
2619 2619 Permission,
2620 2620 UserGroupUserGroupToPerm.permission_id ==
2621 2621 Permission.permission_id)\
2622 2622 .join(
2623 2623 TargetUserGroup,
2624 2624 UserGroupUserGroupToPerm.target_user_group_id ==
2625 2625 TargetUserGroup.users_group_id)\
2626 2626 .join(
2627 2627 UserGroup,
2628 2628 UserGroupUserGroupToPerm.user_group_id ==
2629 2629 UserGroup.users_group_id)\
2630 2630 .join(
2631 2631 UserGroupMember,
2632 2632 UserGroupUserGroupToPerm.user_group_id ==
2633 2633 UserGroupMember.users_group_id)\
2634 2634 .filter(
2635 2635 UserGroupMember.user_id == user_id,
2636 2636 UserGroup.users_group_active == true())
2637 2637 if user_group_id:
2638 2638 q = q.filter(
2639 2639 UserGroupUserGroupToPerm.user_group_id == user_group_id)
2640 2640
2641 2641 return q.all()
2642 2642
2643 2643
2644 2644 class UserRepoToPerm(Base, BaseModel):
2645 2645 __tablename__ = 'repo_to_perm'
2646 2646 __table_args__ = (
2647 2647 UniqueConstraint('user_id', 'repository_id', 'permission_id'),
2648 2648 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2649 2649 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2650 2650 )
2651 2651 repo_to_perm_id = Column("repo_to_perm_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2652 2652 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=None, default=None)
2653 2653 permission_id = Column("permission_id", Integer(), ForeignKey('permissions.permission_id'), nullable=False, unique=None, default=None)
2654 2654 repository_id = Column("repository_id", Integer(), ForeignKey('repositories.repo_id'), nullable=False, unique=None, default=None)
2655 2655
2656 2656 user = relationship('User')
2657 2657 repository = relationship('Repository')
2658 2658 permission = relationship('Permission')
2659 2659
2660 2660 @classmethod
2661 2661 def create(cls, user, repository, permission):
2662 2662 n = cls()
2663 2663 n.user = user
2664 2664 n.repository = repository
2665 2665 n.permission = permission
2666 2666 Session().add(n)
2667 2667 return n
2668 2668
2669 2669 def __unicode__(self):
2670 2670 return u'<%s => %s >' % (self.user, self.repository)
2671 2671
2672 2672
2673 2673 class UserUserGroupToPerm(Base, BaseModel):
2674 2674 __tablename__ = 'user_user_group_to_perm'
2675 2675 __table_args__ = (
2676 2676 UniqueConstraint('user_id', 'user_group_id', 'permission_id'),
2677 2677 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2678 2678 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2679 2679 )
2680 2680 user_user_group_to_perm_id = Column("user_user_group_to_perm_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2681 2681 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=None, default=None)
2682 2682 permission_id = Column("permission_id", Integer(), ForeignKey('permissions.permission_id'), nullable=False, unique=None, default=None)
2683 2683 user_group_id = Column("user_group_id", Integer(), ForeignKey('users_groups.users_group_id'), nullable=False, unique=None, default=None)
2684 2684
2685 2685 user = relationship('User')
2686 2686 user_group = relationship('UserGroup')
2687 2687 permission = relationship('Permission')
2688 2688
2689 2689 @classmethod
2690 2690 def create(cls, user, user_group, permission):
2691 2691 n = cls()
2692 2692 n.user = user
2693 2693 n.user_group = user_group
2694 2694 n.permission = permission
2695 2695 Session().add(n)
2696 2696 return n
2697 2697
2698 2698 def __unicode__(self):
2699 2699 return u'<%s => %s >' % (self.user, self.user_group)
2700 2700
2701 2701
2702 2702 class UserToPerm(Base, BaseModel):
2703 2703 __tablename__ = 'user_to_perm'
2704 2704 __table_args__ = (
2705 2705 UniqueConstraint('user_id', 'permission_id'),
2706 2706 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2707 2707 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2708 2708 )
2709 2709 user_to_perm_id = Column("user_to_perm_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2710 2710 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=None, default=None)
2711 2711 permission_id = Column("permission_id", Integer(), ForeignKey('permissions.permission_id'), nullable=False, unique=None, default=None)
2712 2712
2713 2713 user = relationship('User')
2714 2714 permission = relationship('Permission', lazy='joined')
2715 2715
2716 2716 def __unicode__(self):
2717 2717 return u'<%s => %s >' % (self.user, self.permission)
2718 2718
2719 2719
2720 2720 class UserGroupRepoToPerm(Base, BaseModel):
2721 2721 __tablename__ = 'users_group_repo_to_perm'
2722 2722 __table_args__ = (
2723 2723 UniqueConstraint('repository_id', 'users_group_id', 'permission_id'),
2724 2724 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2725 2725 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2726 2726 )
2727 2727 users_group_to_perm_id = Column("users_group_to_perm_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2728 2728 users_group_id = Column("users_group_id", Integer(), ForeignKey('users_groups.users_group_id'), nullable=False, unique=None, default=None)
2729 2729 permission_id = Column("permission_id", Integer(), ForeignKey('permissions.permission_id'), nullable=False, unique=None, default=None)
2730 2730 repository_id = Column("repository_id", Integer(), ForeignKey('repositories.repo_id'), nullable=False, unique=None, default=None)
2731 2731
2732 2732 users_group = relationship('UserGroup')
2733 2733 permission = relationship('Permission')
2734 2734 repository = relationship('Repository')
2735 2735
2736 2736 @classmethod
2737 2737 def create(cls, users_group, repository, permission):
2738 2738 n = cls()
2739 2739 n.users_group = users_group
2740 2740 n.repository = repository
2741 2741 n.permission = permission
2742 2742 Session().add(n)
2743 2743 return n
2744 2744
2745 2745 def __unicode__(self):
2746 2746 return u'<UserGroupRepoToPerm:%s => %s >' % (self.users_group, self.repository)
2747 2747
2748 2748
2749 2749 class UserGroupUserGroupToPerm(Base, BaseModel):
2750 2750 __tablename__ = 'user_group_user_group_to_perm'
2751 2751 __table_args__ = (
2752 2752 UniqueConstraint('target_user_group_id', 'user_group_id', 'permission_id'),
2753 2753 CheckConstraint('target_user_group_id != user_group_id'),
2754 2754 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2755 2755 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2756 2756 )
2757 2757 user_group_user_group_to_perm_id = Column("user_group_user_group_to_perm_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2758 2758 target_user_group_id = Column("target_user_group_id", Integer(), ForeignKey('users_groups.users_group_id'), nullable=False, unique=None, default=None)
2759 2759 permission_id = Column("permission_id", Integer(), ForeignKey('permissions.permission_id'), nullable=False, unique=None, default=None)
2760 2760 user_group_id = Column("user_group_id", Integer(), ForeignKey('users_groups.users_group_id'), nullable=False, unique=None, default=None)
2761 2761
2762 2762 target_user_group = relationship('UserGroup', primaryjoin='UserGroupUserGroupToPerm.target_user_group_id==UserGroup.users_group_id')
2763 2763 user_group = relationship('UserGroup', primaryjoin='UserGroupUserGroupToPerm.user_group_id==UserGroup.users_group_id')
2764 2764 permission = relationship('Permission')
2765 2765
2766 2766 @classmethod
2767 2767 def create(cls, target_user_group, user_group, permission):
2768 2768 n = cls()
2769 2769 n.target_user_group = target_user_group
2770 2770 n.user_group = user_group
2771 2771 n.permission = permission
2772 2772 Session().add(n)
2773 2773 return n
2774 2774
2775 2775 def __unicode__(self):
2776 2776 return u'<UserGroupUserGroup:%s => %s >' % (self.target_user_group, self.user_group)
2777 2777
2778 2778
2779 2779 class UserGroupToPerm(Base, BaseModel):
2780 2780 __tablename__ = 'users_group_to_perm'
2781 2781 __table_args__ = (
2782 2782 UniqueConstraint('users_group_id', 'permission_id',),
2783 2783 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2784 2784 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2785 2785 )
2786 2786 users_group_to_perm_id = Column("users_group_to_perm_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2787 2787 users_group_id = Column("users_group_id", Integer(), ForeignKey('users_groups.users_group_id'), nullable=False, unique=None, default=None)
2788 2788 permission_id = Column("permission_id", Integer(), ForeignKey('permissions.permission_id'), nullable=False, unique=None, default=None)
2789 2789
2790 2790 users_group = relationship('UserGroup')
2791 2791 permission = relationship('Permission')
2792 2792
2793 2793
2794 2794 class UserRepoGroupToPerm(Base, BaseModel):
2795 2795 __tablename__ = 'user_repo_group_to_perm'
2796 2796 __table_args__ = (
2797 2797 UniqueConstraint('user_id', 'group_id', 'permission_id'),
2798 2798 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2799 2799 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2800 2800 )
2801 2801
2802 2802 group_to_perm_id = Column("group_to_perm_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2803 2803 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=None, default=None)
2804 2804 group_id = Column("group_id", Integer(), ForeignKey('groups.group_id'), nullable=False, unique=None, default=None)
2805 2805 permission_id = Column("permission_id", Integer(), ForeignKey('permissions.permission_id'), nullable=False, unique=None, default=None)
2806 2806
2807 2807 user = relationship('User')
2808 2808 group = relationship('RepoGroup')
2809 2809 permission = relationship('Permission')
2810 2810
2811 2811 @classmethod
2812 2812 def create(cls, user, repository_group, permission):
2813 2813 n = cls()
2814 2814 n.user = user
2815 2815 n.group = repository_group
2816 2816 n.permission = permission
2817 2817 Session().add(n)
2818 2818 return n
2819 2819
2820 2820
2821 2821 class UserGroupRepoGroupToPerm(Base, BaseModel):
2822 2822 __tablename__ = 'users_group_repo_group_to_perm'
2823 2823 __table_args__ = (
2824 2824 UniqueConstraint('users_group_id', 'group_id'),
2825 2825 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2826 2826 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2827 2827 )
2828 2828
2829 2829 users_group_repo_group_to_perm_id = Column("users_group_repo_group_to_perm_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2830 2830 users_group_id = Column("users_group_id", Integer(), ForeignKey('users_groups.users_group_id'), nullable=False, unique=None, default=None)
2831 2831 group_id = Column("group_id", Integer(), ForeignKey('groups.group_id'), nullable=False, unique=None, default=None)
2832 2832 permission_id = Column("permission_id", Integer(), ForeignKey('permissions.permission_id'), nullable=False, unique=None, default=None)
2833 2833
2834 2834 users_group = relationship('UserGroup')
2835 2835 permission = relationship('Permission')
2836 2836 group = relationship('RepoGroup')
2837 2837
2838 2838 @classmethod
2839 2839 def create(cls, user_group, repository_group, permission):
2840 2840 n = cls()
2841 2841 n.users_group = user_group
2842 2842 n.group = repository_group
2843 2843 n.permission = permission
2844 2844 Session().add(n)
2845 2845 return n
2846 2846
2847 2847 def __unicode__(self):
2848 2848 return u'<UserGroupRepoGroupToPerm:%s => %s >' % (self.users_group, self.group)
2849 2849
2850 2850
2851 2851 class Statistics(Base, BaseModel):
2852 2852 __tablename__ = 'statistics'
2853 2853 __table_args__ = (
2854 2854 UniqueConstraint('repository_id'),
2855 2855 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2856 2856 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2857 2857 )
2858 2858 stat_id = Column("stat_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2859 2859 repository_id = Column("repository_id", Integer(), ForeignKey('repositories.repo_id'), nullable=False, unique=True, default=None)
2860 2860 stat_on_revision = Column("stat_on_revision", Integer(), nullable=False)
2861 2861 commit_activity = Column("commit_activity", LargeBinary(1000000), nullable=False)#JSON data
2862 2862 commit_activity_combined = Column("commit_activity_combined", LargeBinary(), nullable=False)#JSON data
2863 2863 languages = Column("languages", LargeBinary(1000000), nullable=False)#JSON data
2864 2864
2865 2865 repository = relationship('Repository', single_parent=True)
2866 2866
2867 2867
2868 2868 class UserFollowing(Base, BaseModel):
2869 2869 __tablename__ = 'user_followings'
2870 2870 __table_args__ = (
2871 2871 UniqueConstraint('user_id', 'follows_repository_id'),
2872 2872 UniqueConstraint('user_id', 'follows_user_id'),
2873 2873 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2874 2874 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
2875 2875 )
2876 2876
2877 2877 user_following_id = Column("user_following_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2878 2878 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=None, default=None)
2879 2879 follows_repo_id = Column("follows_repository_id", Integer(), ForeignKey('repositories.repo_id'), nullable=True, unique=None, default=None)
2880 2880 follows_user_id = Column("follows_user_id", Integer(), ForeignKey('users.user_id'), nullable=True, unique=None, default=None)
2881 2881 follows_from = Column('follows_from', DateTime(timezone=False), nullable=True, unique=None, default=datetime.datetime.now)
2882 2882
2883 2883 user = relationship('User', primaryjoin='User.user_id==UserFollowing.user_id')
2884 2884
2885 2885 follows_user = relationship('User', primaryjoin='User.user_id==UserFollowing.follows_user_id')
2886 2886 follows_repository = relationship('Repository', order_by='Repository.repo_name')
2887 2887
2888 2888 @classmethod
2889 2889 def get_repo_followers(cls, repo_id):
2890 2890 return cls.query().filter(cls.follows_repo_id == repo_id)
2891 2891
2892 2892
2893 2893 class CacheKey(Base, BaseModel):
2894 2894 __tablename__ = 'cache_invalidation'
2895 2895 __table_args__ = (
2896 2896 UniqueConstraint('cache_key'),
2897 2897 Index('key_idx', 'cache_key'),
2898 2898 {'extend_existing': True, 'mysql_engine': 'InnoDB',
2899 2899 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
2900 2900 )
2901 2901 CACHE_TYPE_ATOM = 'ATOM'
2902 2902 CACHE_TYPE_RSS = 'RSS'
2903 2903 CACHE_TYPE_README = 'README'
2904 2904
2905 2905 cache_id = Column("cache_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
2906 2906 cache_key = Column("cache_key", String(255), nullable=True, unique=None, default=None)
2907 2907 cache_args = Column("cache_args", String(255), nullable=True, unique=None, default=None)
2908 2908 cache_active = Column("cache_active", Boolean(), nullable=True, unique=None, default=False)
2909 2909
2910 2910 def __init__(self, cache_key, cache_args=''):
2911 2911 self.cache_key = cache_key
2912 2912 self.cache_args = cache_args
2913 2913 self.cache_active = False
2914 2914
2915 2915 def __unicode__(self):
2916 2916 return u"<%s('%s:%s[%s]')>" % (
2917 2917 self.__class__.__name__,
2918 2918 self.cache_id, self.cache_key, self.cache_active)
2919 2919
2920 2920 def _cache_key_partition(self):
2921 2921 prefix, repo_name, suffix = self.cache_key.partition(self.cache_args)
2922 2922 return prefix, repo_name, suffix
2923 2923
2924 2924 def get_prefix(self):
2925 2925 """
2926 2926 Try to extract prefix from existing cache key. The key could consist
2927 2927 of prefix, repo_name, suffix
2928 2928 """
2929 2929 # this returns prefix, repo_name, suffix
2930 2930 return self._cache_key_partition()[0]
2931 2931
2932 2932 def get_suffix(self):
2933 2933 """
2934 2934 get suffix that might have been used in _get_cache_key to
2935 2935 generate self.cache_key. Only used for informational purposes
2936 2936 in repo_edit.mako.
2937 2937 """
2938 2938 # prefix, repo_name, suffix
2939 2939 return self._cache_key_partition()[2]
2940 2940
2941 2941 @classmethod
2942 2942 def delete_all_cache(cls):
2943 2943 """
2944 2944 Delete all cache keys from database.
2945 2945 Should only be run when all instances are down and all entries
2946 2946 thus stale.
2947 2947 """
2948 2948 cls.query().delete()
2949 2949 Session().commit()
2950 2950
2951 2951 @classmethod
2952 2952 def get_cache_key(cls, repo_name, cache_type):
2953 2953 """
2954 2954
2955 2955 Generate a cache key for this process of RhodeCode instance.
2956 2956 Prefix most likely will be process id or maybe explicitly set
2957 2957 instance_id from .ini file.
2958 2958 """
2959 2959 import rhodecode
2960 2960 prefix = safe_unicode(rhodecode.CONFIG.get('instance_id') or '')
2961 2961
2962 2962 repo_as_unicode = safe_unicode(repo_name)
2963 2963 key = u'{}_{}'.format(repo_as_unicode, cache_type) \
2964 2964 if cache_type else repo_as_unicode
2965 2965
2966 2966 return u'{}{}'.format(prefix, key)
2967 2967
2968 2968 @classmethod
2969 2969 def set_invalidate(cls, repo_name, delete=False):
2970 2970 """
2971 2971 Mark all caches of a repo as invalid in the database.
2972 2972 """
2973 2973
2974 2974 try:
2975 2975 qry = Session().query(cls).filter(cls.cache_args == repo_name)
2976 2976 if delete:
2977 2977 log.debug('cache objects deleted for repo %s',
2978 2978 safe_str(repo_name))
2979 2979 qry.delete()
2980 2980 else:
2981 2981 log.debug('cache objects marked as invalid for repo %s',
2982 2982 safe_str(repo_name))
2983 2983 qry.update({"cache_active": False})
2984 2984
2985 2985 Session().commit()
2986 2986 except Exception:
2987 2987 log.exception(
2988 2988 'Cache key invalidation failed for repository %s',
2989 2989 safe_str(repo_name))
2990 2990 Session().rollback()
2991 2991
2992 2992 @classmethod
2993 2993 def get_active_cache(cls, cache_key):
2994 2994 inv_obj = cls.query().filter(cls.cache_key == cache_key).scalar()
2995 2995 if inv_obj:
2996 2996 return inv_obj
2997 2997 return None
2998 2998
2999 2999 @classmethod
3000 3000 def repo_context_cache(cls, compute_func, repo_name, cache_type,
3001 3001 thread_scoped=False):
3002 3002 """
3003 3003 @cache_region('long_term')
3004 3004 def _heavy_calculation(cache_key):
3005 3005 return 'result'
3006 3006
3007 3007 cache_context = CacheKey.repo_context_cache(
3008 3008 _heavy_calculation, repo_name, cache_type)
3009 3009
3010 3010 with cache_context as context:
3011 3011 context.invalidate()
3012 3012 computed = context.compute()
3013 3013
3014 3014 assert computed == 'result'
3015 3015 """
3016 3016 from rhodecode.lib import caches
3017 3017 return caches.InvalidationContext(
3018 3018 compute_func, repo_name, cache_type, thread_scoped=thread_scoped)
3019 3019
3020 3020
3021 3021 class ChangesetComment(Base, BaseModel):
3022 3022 __tablename__ = 'changeset_comments'
3023 3023 __table_args__ = (
3024 3024 Index('cc_revision_idx', 'revision'),
3025 3025 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3026 3026 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
3027 3027 )
3028 3028
3029 3029 COMMENT_OUTDATED = u'comment_outdated'
3030 3030 COMMENT_TYPE_NOTE = u'note'
3031 3031 COMMENT_TYPE_TODO = u'todo'
3032 3032 COMMENT_TYPES = [COMMENT_TYPE_NOTE, COMMENT_TYPE_TODO]
3033 3033
3034 3034 comment_id = Column('comment_id', Integer(), nullable=False, primary_key=True)
3035 3035 repo_id = Column('repo_id', Integer(), ForeignKey('repositories.repo_id'), nullable=False)
3036 3036 revision = Column('revision', String(40), nullable=True)
3037 3037 pull_request_id = Column("pull_request_id", Integer(), ForeignKey('pull_requests.pull_request_id'), nullable=True)
3038 3038 pull_request_version_id = Column("pull_request_version_id", Integer(), ForeignKey('pull_request_versions.pull_request_version_id'), nullable=True)
3039 3039 line_no = Column('line_no', Unicode(10), nullable=True)
3040 3040 hl_lines = Column('hl_lines', Unicode(512), nullable=True)
3041 3041 f_path = Column('f_path', Unicode(1000), nullable=True)
3042 3042 user_id = Column('user_id', Integer(), ForeignKey('users.user_id'), nullable=False)
3043 3043 text = Column('text', UnicodeText().with_variant(UnicodeText(25000), 'mysql'), nullable=False)
3044 3044 created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
3045 3045 modified_at = Column('modified_at', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
3046 3046 renderer = Column('renderer', Unicode(64), nullable=True)
3047 3047 display_state = Column('display_state', Unicode(128), nullable=True)
3048 3048
3049 3049 comment_type = Column('comment_type', Unicode(128), nullable=True, default=COMMENT_TYPE_NOTE)
3050 3050 resolved_comment_id = Column('resolved_comment_id', Integer(), ForeignKey('changeset_comments.comment_id'), nullable=True)
3051 3051 resolved_comment = relationship('ChangesetComment', remote_side=comment_id, backref='resolved_by')
3052 3052 author = relationship('User', lazy='joined')
3053 3053 repo = relationship('Repository')
3054 3054 status_change = relationship('ChangesetStatus', cascade="all, delete, delete-orphan", lazy='joined')
3055 3055 pull_request = relationship('PullRequest', lazy='joined')
3056 3056 pull_request_version = relationship('PullRequestVersion')
3057 3057
3058 3058 @classmethod
3059 3059 def get_users(cls, revision=None, pull_request_id=None):
3060 3060 """
3061 3061 Returns user associated with this ChangesetComment. ie those
3062 3062 who actually commented
3063 3063
3064 3064 :param cls:
3065 3065 :param revision:
3066 3066 """
3067 3067 q = Session().query(User)\
3068 3068 .join(ChangesetComment.author)
3069 3069 if revision:
3070 3070 q = q.filter(cls.revision == revision)
3071 3071 elif pull_request_id:
3072 3072 q = q.filter(cls.pull_request_id == pull_request_id)
3073 3073 return q.all()
3074 3074
3075 3075 @classmethod
3076 3076 def get_index_from_version(cls, pr_version, versions):
3077 3077 num_versions = [x.pull_request_version_id for x in versions]
3078 3078 try:
3079 3079 return num_versions.index(pr_version) +1
3080 3080 except (IndexError, ValueError):
3081 3081 return
3082 3082
3083 3083 @property
3084 3084 def outdated(self):
3085 3085 return self.display_state == self.COMMENT_OUTDATED
3086 3086
3087 3087 def outdated_at_version(self, version):
3088 3088 """
3089 3089 Checks if comment is outdated for given pull request version
3090 3090 """
3091 3091 return self.outdated and self.pull_request_version_id != version
3092 3092
3093 3093 def older_than_version(self, version):
3094 3094 """
3095 3095 Checks if comment is made from previous version than given
3096 3096 """
3097 3097 if version is None:
3098 3098 return self.pull_request_version_id is not None
3099 3099
3100 3100 return self.pull_request_version_id < version
3101 3101
3102 3102 @property
3103 3103 def resolved(self):
3104 3104 return self.resolved_by[0] if self.resolved_by else None
3105 3105
3106 3106 @property
3107 3107 def is_todo(self):
3108 3108 return self.comment_type == self.COMMENT_TYPE_TODO
3109 3109
3110 @property
3111 def is_inline(self):
3112 return self.line_no and self.f_path
3113
3110 3114 def get_index_version(self, versions):
3111 3115 return self.get_index_from_version(
3112 3116 self.pull_request_version_id, versions)
3113 3117
3114 3118 def __repr__(self):
3115 3119 if self.comment_id:
3116 3120 return '<DB:Comment #%s>' % self.comment_id
3117 3121 else:
3118 3122 return '<DB:Comment at %#x>' % id(self)
3119 3123
3120 3124
3121 3125 class ChangesetStatus(Base, BaseModel):
3122 3126 __tablename__ = 'changeset_statuses'
3123 3127 __table_args__ = (
3124 3128 Index('cs_revision_idx', 'revision'),
3125 3129 Index('cs_version_idx', 'version'),
3126 3130 UniqueConstraint('repo_id', 'revision', 'version'),
3127 3131 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3128 3132 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
3129 3133 )
3130 3134 STATUS_NOT_REVIEWED = DEFAULT = 'not_reviewed'
3131 3135 STATUS_APPROVED = 'approved'
3132 3136 STATUS_REJECTED = 'rejected'
3133 3137 STATUS_UNDER_REVIEW = 'under_review'
3134 3138
3135 3139 STATUSES = [
3136 3140 (STATUS_NOT_REVIEWED, _("Not Reviewed")), # (no icon) and default
3137 3141 (STATUS_APPROVED, _("Approved")),
3138 3142 (STATUS_REJECTED, _("Rejected")),
3139 3143 (STATUS_UNDER_REVIEW, _("Under Review")),
3140 3144 ]
3141 3145
3142 3146 changeset_status_id = Column('changeset_status_id', Integer(), nullable=False, primary_key=True)
3143 3147 repo_id = Column('repo_id', Integer(), ForeignKey('repositories.repo_id'), nullable=False)
3144 3148 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False, unique=None)
3145 3149 revision = Column('revision', String(40), nullable=False)
3146 3150 status = Column('status', String(128), nullable=False, default=DEFAULT)
3147 3151 changeset_comment_id = Column('changeset_comment_id', Integer(), ForeignKey('changeset_comments.comment_id'))
3148 3152 modified_at = Column('modified_at', DateTime(), nullable=False, default=datetime.datetime.now)
3149 3153 version = Column('version', Integer(), nullable=False, default=0)
3150 3154 pull_request_id = Column("pull_request_id", Integer(), ForeignKey('pull_requests.pull_request_id'), nullable=True)
3151 3155
3152 3156 author = relationship('User', lazy='joined')
3153 3157 repo = relationship('Repository')
3154 3158 comment = relationship('ChangesetComment', lazy='joined')
3155 3159 pull_request = relationship('PullRequest', lazy='joined')
3156 3160
3157 3161 def __unicode__(self):
3158 3162 return u"<%s('%s[v%s]:%s')>" % (
3159 3163 self.__class__.__name__,
3160 3164 self.status, self.version, self.author
3161 3165 )
3162 3166
3163 3167 @classmethod
3164 3168 def get_status_lbl(cls, value):
3165 3169 return dict(cls.STATUSES).get(value)
3166 3170
3167 3171 @property
3168 3172 def status_lbl(self):
3169 3173 return ChangesetStatus.get_status_lbl(self.status)
3170 3174
3171 3175
3172 3176 class _PullRequestBase(BaseModel):
3173 3177 """
3174 3178 Common attributes of pull request and version entries.
3175 3179 """
3176 3180
3177 3181 # .status values
3178 3182 STATUS_NEW = u'new'
3179 3183 STATUS_OPEN = u'open'
3180 3184 STATUS_CLOSED = u'closed'
3181 3185
3182 3186 title = Column('title', Unicode(255), nullable=True)
3183 3187 description = Column(
3184 3188 'description', UnicodeText().with_variant(UnicodeText(10240), 'mysql'),
3185 3189 nullable=True)
3186 3190 # new/open/closed status of pull request (not approve/reject/etc)
3187 3191 status = Column('status', Unicode(255), nullable=False, default=STATUS_NEW)
3188 3192 created_on = Column(
3189 3193 'created_on', DateTime(timezone=False), nullable=False,
3190 3194 default=datetime.datetime.now)
3191 3195 updated_on = Column(
3192 3196 'updated_on', DateTime(timezone=False), nullable=False,
3193 3197 default=datetime.datetime.now)
3194 3198
3195 3199 @declared_attr
3196 3200 def user_id(cls):
3197 3201 return Column(
3198 3202 "user_id", Integer(), ForeignKey('users.user_id'), nullable=False,
3199 3203 unique=None)
3200 3204
3201 3205 # 500 revisions max
3202 3206 _revisions = Column(
3203 3207 'revisions', UnicodeText().with_variant(UnicodeText(20500), 'mysql'))
3204 3208
3205 3209 @declared_attr
3206 3210 def source_repo_id(cls):
3207 3211 # TODO: dan: rename column to source_repo_id
3208 3212 return Column(
3209 3213 'org_repo_id', Integer(), ForeignKey('repositories.repo_id'),
3210 3214 nullable=False)
3211 3215
3212 3216 source_ref = Column('org_ref', Unicode(255), nullable=False)
3213 3217
3214 3218 @declared_attr
3215 3219 def target_repo_id(cls):
3216 3220 # TODO: dan: rename column to target_repo_id
3217 3221 return Column(
3218 3222 'other_repo_id', Integer(), ForeignKey('repositories.repo_id'),
3219 3223 nullable=False)
3220 3224
3221 3225 target_ref = Column('other_ref', Unicode(255), nullable=False)
3222 3226 _shadow_merge_ref = Column('shadow_merge_ref', Unicode(255), nullable=True)
3223 3227
3224 3228 # TODO: dan: rename column to last_merge_source_rev
3225 3229 _last_merge_source_rev = Column(
3226 3230 'last_merge_org_rev', String(40), nullable=True)
3227 3231 # TODO: dan: rename column to last_merge_target_rev
3228 3232 _last_merge_target_rev = Column(
3229 3233 'last_merge_other_rev', String(40), nullable=True)
3230 3234 _last_merge_status = Column('merge_status', Integer(), nullable=True)
3231 3235 merge_rev = Column('merge_rev', String(40), nullable=True)
3232 3236
3233 3237 reviewer_data = Column(
3234 3238 'reviewer_data_json', MutationObj.as_mutable(
3235 3239 JsonType(dialect_map=dict(mysql=UnicodeText(16384)))))
3236 3240
3237 3241 @property
3238 3242 def reviewer_data_json(self):
3239 3243 return json.dumps(self.reviewer_data)
3240 3244
3241 3245 @hybrid_property
3242 3246 def revisions(self):
3243 3247 return self._revisions.split(':') if self._revisions else []
3244 3248
3245 3249 @revisions.setter
3246 3250 def revisions(self, val):
3247 3251 self._revisions = ':'.join(val)
3248 3252
3249 3253 @declared_attr
3250 3254 def author(cls):
3251 3255 return relationship('User', lazy='joined')
3252 3256
3253 3257 @declared_attr
3254 3258 def source_repo(cls):
3255 3259 return relationship(
3256 3260 'Repository',
3257 3261 primaryjoin='%s.source_repo_id==Repository.repo_id' % cls.__name__)
3258 3262
3259 3263 @property
3260 3264 def source_ref_parts(self):
3261 3265 return self.unicode_to_reference(self.source_ref)
3262 3266
3263 3267 @declared_attr
3264 3268 def target_repo(cls):
3265 3269 return relationship(
3266 3270 'Repository',
3267 3271 primaryjoin='%s.target_repo_id==Repository.repo_id' % cls.__name__)
3268 3272
3269 3273 @property
3270 3274 def target_ref_parts(self):
3271 3275 return self.unicode_to_reference(self.target_ref)
3272 3276
3273 3277 @property
3274 3278 def shadow_merge_ref(self):
3275 3279 return self.unicode_to_reference(self._shadow_merge_ref)
3276 3280
3277 3281 @shadow_merge_ref.setter
3278 3282 def shadow_merge_ref(self, ref):
3279 3283 self._shadow_merge_ref = self.reference_to_unicode(ref)
3280 3284
3281 3285 def unicode_to_reference(self, raw):
3282 3286 """
3283 3287 Convert a unicode (or string) to a reference object.
3284 3288 If unicode evaluates to False it returns None.
3285 3289 """
3286 3290 if raw:
3287 3291 refs = raw.split(':')
3288 3292 return Reference(*refs)
3289 3293 else:
3290 3294 return None
3291 3295
3292 3296 def reference_to_unicode(self, ref):
3293 3297 """
3294 3298 Convert a reference object to unicode.
3295 3299 If reference is None it returns None.
3296 3300 """
3297 3301 if ref:
3298 3302 return u':'.join(ref)
3299 3303 else:
3300 3304 return None
3301 3305
3302 3306 def get_api_data(self):
3303 3307 from pylons import url
3304 3308 from rhodecode.model.pull_request import PullRequestModel
3305 3309 pull_request = self
3306 3310 merge_status = PullRequestModel().merge_status(pull_request)
3307 3311
3308 3312 pull_request_url = url(
3309 3313 'pullrequest_show', repo_name=self.target_repo.repo_name,
3310 3314 pull_request_id=self.pull_request_id, qualified=True)
3311 3315
3312 3316 merge_data = {
3313 3317 'clone_url': PullRequestModel().get_shadow_clone_url(pull_request),
3314 3318 'reference': (
3315 3319 pull_request.shadow_merge_ref._asdict()
3316 3320 if pull_request.shadow_merge_ref else None),
3317 3321 }
3318 3322
3319 3323 data = {
3320 3324 'pull_request_id': pull_request.pull_request_id,
3321 3325 'url': pull_request_url,
3322 3326 'title': pull_request.title,
3323 3327 'description': pull_request.description,
3324 3328 'status': pull_request.status,
3325 3329 'created_on': pull_request.created_on,
3326 3330 'updated_on': pull_request.updated_on,
3327 3331 'commit_ids': pull_request.revisions,
3328 3332 'review_status': pull_request.calculated_review_status(),
3329 3333 'mergeable': {
3330 3334 'status': merge_status[0],
3331 3335 'message': unicode(merge_status[1]),
3332 3336 },
3333 3337 'source': {
3334 3338 'clone_url': pull_request.source_repo.clone_url(),
3335 3339 'repository': pull_request.source_repo.repo_name,
3336 3340 'reference': {
3337 3341 'name': pull_request.source_ref_parts.name,
3338 3342 'type': pull_request.source_ref_parts.type,
3339 3343 'commit_id': pull_request.source_ref_parts.commit_id,
3340 3344 },
3341 3345 },
3342 3346 'target': {
3343 3347 'clone_url': pull_request.target_repo.clone_url(),
3344 3348 'repository': pull_request.target_repo.repo_name,
3345 3349 'reference': {
3346 3350 'name': pull_request.target_ref_parts.name,
3347 3351 'type': pull_request.target_ref_parts.type,
3348 3352 'commit_id': pull_request.target_ref_parts.commit_id,
3349 3353 },
3350 3354 },
3351 3355 'merge': merge_data,
3352 3356 'author': pull_request.author.get_api_data(include_secrets=False,
3353 3357 details='basic'),
3354 3358 'reviewers': [
3355 3359 {
3356 3360 'user': reviewer.get_api_data(include_secrets=False,
3357 3361 details='basic'),
3358 3362 'reasons': reasons,
3359 3363 'review_status': st[0][1].status if st else 'not_reviewed',
3360 3364 }
3361 3365 for reviewer, reasons, mandatory, st in
3362 3366 pull_request.reviewers_statuses()
3363 3367 ]
3364 3368 }
3365 3369
3366 3370 return data
3367 3371
3368 3372
3369 3373 class PullRequest(Base, _PullRequestBase):
3370 3374 __tablename__ = 'pull_requests'
3371 3375 __table_args__ = (
3372 3376 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3373 3377 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
3374 3378 )
3375 3379
3376 3380 pull_request_id = Column(
3377 3381 'pull_request_id', Integer(), nullable=False, primary_key=True)
3378 3382
3379 3383 def __repr__(self):
3380 3384 if self.pull_request_id:
3381 3385 return '<DB:PullRequest #%s>' % self.pull_request_id
3382 3386 else:
3383 3387 return '<DB:PullRequest at %#x>' % id(self)
3384 3388
3385 3389 reviewers = relationship('PullRequestReviewers',
3386 3390 cascade="all, delete, delete-orphan")
3387 3391 statuses = relationship('ChangesetStatus')
3388 3392 comments = relationship('ChangesetComment',
3389 3393 cascade="all, delete, delete-orphan")
3390 3394 versions = relationship('PullRequestVersion',
3391 3395 cascade="all, delete, delete-orphan",
3392 3396 lazy='dynamic')
3393 3397
3394 3398 @classmethod
3395 3399 def get_pr_display_object(cls, pull_request_obj, org_pull_request_obj,
3396 3400 internal_methods=None):
3397 3401
3398 3402 class PullRequestDisplay(object):
3399 3403 """
3400 3404 Special object wrapper for showing PullRequest data via Versions
3401 3405 It mimics PR object as close as possible. This is read only object
3402 3406 just for display
3403 3407 """
3404 3408
3405 3409 def __init__(self, attrs, internal=None):
3406 3410 self.attrs = attrs
3407 3411 # internal have priority over the given ones via attrs
3408 3412 self.internal = internal or ['versions']
3409 3413
3410 3414 def __getattr__(self, item):
3411 3415 if item in self.internal:
3412 3416 return getattr(self, item)
3413 3417 try:
3414 3418 return self.attrs[item]
3415 3419 except KeyError:
3416 3420 raise AttributeError(
3417 3421 '%s object has no attribute %s' % (self, item))
3418 3422
3419 3423 def __repr__(self):
3420 3424 return '<DB:PullRequestDisplay #%s>' % self.attrs.get('pull_request_id')
3421 3425
3422 3426 def versions(self):
3423 3427 return pull_request_obj.versions.order_by(
3424 3428 PullRequestVersion.pull_request_version_id).all()
3425 3429
3426 3430 def is_closed(self):
3427 3431 return pull_request_obj.is_closed()
3428 3432
3429 3433 @property
3430 3434 def pull_request_version_id(self):
3431 3435 return getattr(pull_request_obj, 'pull_request_version_id', None)
3432 3436
3433 3437 attrs = StrictAttributeDict(pull_request_obj.get_api_data())
3434 3438
3435 3439 attrs.author = StrictAttributeDict(
3436 3440 pull_request_obj.author.get_api_data())
3437 3441 if pull_request_obj.target_repo:
3438 3442 attrs.target_repo = StrictAttributeDict(
3439 3443 pull_request_obj.target_repo.get_api_data())
3440 3444 attrs.target_repo.clone_url = pull_request_obj.target_repo.clone_url
3441 3445
3442 3446 if pull_request_obj.source_repo:
3443 3447 attrs.source_repo = StrictAttributeDict(
3444 3448 pull_request_obj.source_repo.get_api_data())
3445 3449 attrs.source_repo.clone_url = pull_request_obj.source_repo.clone_url
3446 3450
3447 3451 attrs.source_ref_parts = pull_request_obj.source_ref_parts
3448 3452 attrs.target_ref_parts = pull_request_obj.target_ref_parts
3449 3453 attrs.revisions = pull_request_obj.revisions
3450 3454
3451 3455 attrs.shadow_merge_ref = org_pull_request_obj.shadow_merge_ref
3452 3456 attrs.reviewer_data = org_pull_request_obj.reviewer_data
3453 3457 attrs.reviewer_data_json = org_pull_request_obj.reviewer_data_json
3454 3458
3455 3459 return PullRequestDisplay(attrs, internal=internal_methods)
3456 3460
3457 3461 def is_closed(self):
3458 3462 return self.status == self.STATUS_CLOSED
3459 3463
3460 3464 def __json__(self):
3461 3465 return {
3462 3466 'revisions': self.revisions,
3463 3467 }
3464 3468
3465 3469 def calculated_review_status(self):
3466 3470 from rhodecode.model.changeset_status import ChangesetStatusModel
3467 3471 return ChangesetStatusModel().calculated_review_status(self)
3468 3472
3469 3473 def reviewers_statuses(self):
3470 3474 from rhodecode.model.changeset_status import ChangesetStatusModel
3471 3475 return ChangesetStatusModel().reviewers_statuses(self)
3472 3476
3473 3477 @property
3474 3478 def workspace_id(self):
3475 3479 from rhodecode.model.pull_request import PullRequestModel
3476 3480 return PullRequestModel()._workspace_id(self)
3477 3481
3478 3482 def get_shadow_repo(self):
3479 3483 workspace_id = self.workspace_id
3480 3484 vcs_obj = self.target_repo.scm_instance()
3481 3485 shadow_repository_path = vcs_obj._get_shadow_repository_path(
3482 3486 workspace_id)
3483 3487 return vcs_obj._get_shadow_instance(shadow_repository_path)
3484 3488
3485 3489
3486 3490 class PullRequestVersion(Base, _PullRequestBase):
3487 3491 __tablename__ = 'pull_request_versions'
3488 3492 __table_args__ = (
3489 3493 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3490 3494 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
3491 3495 )
3492 3496
3493 3497 pull_request_version_id = Column(
3494 3498 'pull_request_version_id', Integer(), nullable=False, primary_key=True)
3495 3499 pull_request_id = Column(
3496 3500 'pull_request_id', Integer(),
3497 3501 ForeignKey('pull_requests.pull_request_id'), nullable=False)
3498 3502 pull_request = relationship('PullRequest')
3499 3503
3500 3504 def __repr__(self):
3501 3505 if self.pull_request_version_id:
3502 3506 return '<DB:PullRequestVersion #%s>' % self.pull_request_version_id
3503 3507 else:
3504 3508 return '<DB:PullRequestVersion at %#x>' % id(self)
3505 3509
3506 3510 @property
3507 3511 def reviewers(self):
3508 3512 return self.pull_request.reviewers
3509 3513
3510 3514 @property
3511 3515 def versions(self):
3512 3516 return self.pull_request.versions
3513 3517
3514 3518 def is_closed(self):
3515 3519 # calculate from original
3516 3520 return self.pull_request.status == self.STATUS_CLOSED
3517 3521
3518 3522 def calculated_review_status(self):
3519 3523 return self.pull_request.calculated_review_status()
3520 3524
3521 3525 def reviewers_statuses(self):
3522 3526 return self.pull_request.reviewers_statuses()
3523 3527
3524 3528
3525 3529 class PullRequestReviewers(Base, BaseModel):
3526 3530 __tablename__ = 'pull_request_reviewers'
3527 3531 __table_args__ = (
3528 3532 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3529 3533 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
3530 3534 )
3531 3535
3532 3536 @hybrid_property
3533 3537 def reasons(self):
3534 3538 if not self._reasons:
3535 3539 return []
3536 3540 return self._reasons
3537 3541
3538 3542 @reasons.setter
3539 3543 def reasons(self, val):
3540 3544 val = val or []
3541 3545 if any(not isinstance(x, basestring) for x in val):
3542 3546 raise Exception('invalid reasons type, must be list of strings')
3543 3547 self._reasons = val
3544 3548
3545 3549 pull_requests_reviewers_id = Column(
3546 3550 'pull_requests_reviewers_id', Integer(), nullable=False,
3547 3551 primary_key=True)
3548 3552 pull_request_id = Column(
3549 3553 "pull_request_id", Integer(),
3550 3554 ForeignKey('pull_requests.pull_request_id'), nullable=False)
3551 3555 user_id = Column(
3552 3556 "user_id", Integer(), ForeignKey('users.user_id'), nullable=True)
3553 3557 _reasons = Column(
3554 3558 'reason', MutationList.as_mutable(
3555 3559 JsonType('list', dialect_map=dict(mysql=UnicodeText(16384)))))
3556 3560 mandatory = Column("mandatory", Boolean(), nullable=False, default=False)
3557 3561 user = relationship('User')
3558 3562 pull_request = relationship('PullRequest')
3559 3563
3560 3564
3561 3565 class Notification(Base, BaseModel):
3562 3566 __tablename__ = 'notifications'
3563 3567 __table_args__ = (
3564 3568 Index('notification_type_idx', 'type'),
3565 3569 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3566 3570 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
3567 3571 )
3568 3572
3569 3573 TYPE_CHANGESET_COMMENT = u'cs_comment'
3570 3574 TYPE_MESSAGE = u'message'
3571 3575 TYPE_MENTION = u'mention'
3572 3576 TYPE_REGISTRATION = u'registration'
3573 3577 TYPE_PULL_REQUEST = u'pull_request'
3574 3578 TYPE_PULL_REQUEST_COMMENT = u'pull_request_comment'
3575 3579
3576 3580 notification_id = Column('notification_id', Integer(), nullable=False, primary_key=True)
3577 3581 subject = Column('subject', Unicode(512), nullable=True)
3578 3582 body = Column('body', UnicodeText().with_variant(UnicodeText(50000), 'mysql'), nullable=True)
3579 3583 created_by = Column("created_by", Integer(), ForeignKey('users.user_id'), nullable=True)
3580 3584 created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
3581 3585 type_ = Column('type', Unicode(255))
3582 3586
3583 3587 created_by_user = relationship('User')
3584 3588 notifications_to_users = relationship('UserNotification', lazy='joined',
3585 3589 cascade="all, delete, delete-orphan")
3586 3590
3587 3591 @property
3588 3592 def recipients(self):
3589 3593 return [x.user for x in UserNotification.query()\
3590 3594 .filter(UserNotification.notification == self)\
3591 3595 .order_by(UserNotification.user_id.asc()).all()]
3592 3596
3593 3597 @classmethod
3594 3598 def create(cls, created_by, subject, body, recipients, type_=None):
3595 3599 if type_ is None:
3596 3600 type_ = Notification.TYPE_MESSAGE
3597 3601
3598 3602 notification = cls()
3599 3603 notification.created_by_user = created_by
3600 3604 notification.subject = subject
3601 3605 notification.body = body
3602 3606 notification.type_ = type_
3603 3607 notification.created_on = datetime.datetime.now()
3604 3608
3605 3609 for u in recipients:
3606 3610 assoc = UserNotification()
3607 3611 assoc.notification = notification
3608 3612
3609 3613 # if created_by is inside recipients mark his notification
3610 3614 # as read
3611 3615 if u.user_id == created_by.user_id:
3612 3616 assoc.read = True
3613 3617
3614 3618 u.notifications.append(assoc)
3615 3619 Session().add(notification)
3616 3620
3617 3621 return notification
3618 3622
3619 3623 @property
3620 3624 def description(self):
3621 3625 from rhodecode.model.notification import NotificationModel
3622 3626 return NotificationModel().make_description(self)
3623 3627
3624 3628
3625 3629 class UserNotification(Base, BaseModel):
3626 3630 __tablename__ = 'user_to_notification'
3627 3631 __table_args__ = (
3628 3632 UniqueConstraint('user_id', 'notification_id'),
3629 3633 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3630 3634 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
3631 3635 )
3632 3636 user_id = Column('user_id', Integer(), ForeignKey('users.user_id'), primary_key=True)
3633 3637 notification_id = Column("notification_id", Integer(), ForeignKey('notifications.notification_id'), primary_key=True)
3634 3638 read = Column('read', Boolean, default=False)
3635 3639 sent_on = Column('sent_on', DateTime(timezone=False), nullable=True, unique=None)
3636 3640
3637 3641 user = relationship('User', lazy="joined")
3638 3642 notification = relationship('Notification', lazy="joined",
3639 3643 order_by=lambda: Notification.created_on.desc(),)
3640 3644
3641 3645 def mark_as_read(self):
3642 3646 self.read = True
3643 3647 Session().add(self)
3644 3648
3645 3649
3646 3650 class Gist(Base, BaseModel):
3647 3651 __tablename__ = 'gists'
3648 3652 __table_args__ = (
3649 3653 Index('g_gist_access_id_idx', 'gist_access_id'),
3650 3654 Index('g_created_on_idx', 'created_on'),
3651 3655 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3652 3656 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
3653 3657 )
3654 3658 GIST_PUBLIC = u'public'
3655 3659 GIST_PRIVATE = u'private'
3656 3660 DEFAULT_FILENAME = u'gistfile1.txt'
3657 3661
3658 3662 ACL_LEVEL_PUBLIC = u'acl_public'
3659 3663 ACL_LEVEL_PRIVATE = u'acl_private'
3660 3664
3661 3665 gist_id = Column('gist_id', Integer(), primary_key=True)
3662 3666 gist_access_id = Column('gist_access_id', Unicode(250))
3663 3667 gist_description = Column('gist_description', UnicodeText().with_variant(UnicodeText(1024), 'mysql'))
3664 3668 gist_owner = Column('user_id', Integer(), ForeignKey('users.user_id'), nullable=True)
3665 3669 gist_expires = Column('gist_expires', Float(53), nullable=False)
3666 3670 gist_type = Column('gist_type', Unicode(128), nullable=False)
3667 3671 created_on = Column('created_on', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
3668 3672 modified_at = Column('modified_at', DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
3669 3673 acl_level = Column('acl_level', Unicode(128), nullable=True)
3670 3674
3671 3675 owner = relationship('User')
3672 3676
3673 3677 def __repr__(self):
3674 3678 return '<Gist:[%s]%s>' % (self.gist_type, self.gist_access_id)
3675 3679
3676 3680 @classmethod
3677 3681 def get_or_404(cls, id_, pyramid_exc=False):
3678 3682
3679 3683 if pyramid_exc:
3680 3684 from pyramid.httpexceptions import HTTPNotFound
3681 3685 else:
3682 3686 from webob.exc import HTTPNotFound
3683 3687
3684 3688 res = cls.query().filter(cls.gist_access_id == id_).scalar()
3685 3689 if not res:
3686 3690 raise HTTPNotFound
3687 3691 return res
3688 3692
3689 3693 @classmethod
3690 3694 def get_by_access_id(cls, gist_access_id):
3691 3695 return cls.query().filter(cls.gist_access_id == gist_access_id).scalar()
3692 3696
3693 3697 def gist_url(self):
3694 3698 import rhodecode
3695 3699 from pylons import url
3696 3700
3697 3701 alias_url = rhodecode.CONFIG.get('gist_alias_url')
3698 3702 if alias_url:
3699 3703 return alias_url.replace('{gistid}', self.gist_access_id)
3700 3704
3701 3705 return url('gist', gist_id=self.gist_access_id, qualified=True)
3702 3706
3703 3707 @classmethod
3704 3708 def base_path(cls):
3705 3709 """
3706 3710 Returns base path when all gists are stored
3707 3711
3708 3712 :param cls:
3709 3713 """
3710 3714 from rhodecode.model.gist import GIST_STORE_LOC
3711 3715 q = Session().query(RhodeCodeUi)\
3712 3716 .filter(RhodeCodeUi.ui_key == URL_SEP)
3713 3717 q = q.options(FromCache("sql_cache_short", "repository_repo_path"))
3714 3718 return os.path.join(q.one().ui_value, GIST_STORE_LOC)
3715 3719
3716 3720 def get_api_data(self):
3717 3721 """
3718 3722 Common function for generating gist related data for API
3719 3723 """
3720 3724 gist = self
3721 3725 data = {
3722 3726 'gist_id': gist.gist_id,
3723 3727 'type': gist.gist_type,
3724 3728 'access_id': gist.gist_access_id,
3725 3729 'description': gist.gist_description,
3726 3730 'url': gist.gist_url(),
3727 3731 'expires': gist.gist_expires,
3728 3732 'created_on': gist.created_on,
3729 3733 'modified_at': gist.modified_at,
3730 3734 'content': None,
3731 3735 'acl_level': gist.acl_level,
3732 3736 }
3733 3737 return data
3734 3738
3735 3739 def __json__(self):
3736 3740 data = dict(
3737 3741 )
3738 3742 data.update(self.get_api_data())
3739 3743 return data
3740 3744 # SCM functions
3741 3745
3742 3746 def scm_instance(self, **kwargs):
3743 3747 full_repo_path = os.path.join(self.base_path(), self.gist_access_id)
3744 3748 return get_vcs_instance(
3745 3749 repo_path=safe_str(full_repo_path), create=False)
3746 3750
3747 3751
3748 3752 class ExternalIdentity(Base, BaseModel):
3749 3753 __tablename__ = 'external_identities'
3750 3754 __table_args__ = (
3751 3755 Index('local_user_id_idx', 'local_user_id'),
3752 3756 Index('external_id_idx', 'external_id'),
3753 3757 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3754 3758 'mysql_charset': 'utf8'})
3755 3759
3756 3760 external_id = Column('external_id', Unicode(255), default=u'',
3757 3761 primary_key=True)
3758 3762 external_username = Column('external_username', Unicode(1024), default=u'')
3759 3763 local_user_id = Column('local_user_id', Integer(),
3760 3764 ForeignKey('users.user_id'), primary_key=True)
3761 3765 provider_name = Column('provider_name', Unicode(255), default=u'',
3762 3766 primary_key=True)
3763 3767 access_token = Column('access_token', String(1024), default=u'')
3764 3768 alt_token = Column('alt_token', String(1024), default=u'')
3765 3769 token_secret = Column('token_secret', String(1024), default=u'')
3766 3770
3767 3771 @classmethod
3768 3772 def by_external_id_and_provider(cls, external_id, provider_name,
3769 3773 local_user_id=None):
3770 3774 """
3771 3775 Returns ExternalIdentity instance based on search params
3772 3776
3773 3777 :param external_id:
3774 3778 :param provider_name:
3775 3779 :return: ExternalIdentity
3776 3780 """
3777 3781 query = cls.query()
3778 3782 query = query.filter(cls.external_id == external_id)
3779 3783 query = query.filter(cls.provider_name == provider_name)
3780 3784 if local_user_id:
3781 3785 query = query.filter(cls.local_user_id == local_user_id)
3782 3786 return query.first()
3783 3787
3784 3788 @classmethod
3785 3789 def user_by_external_id_and_provider(cls, external_id, provider_name):
3786 3790 """
3787 3791 Returns User instance based on search params
3788 3792
3789 3793 :param external_id:
3790 3794 :param provider_name:
3791 3795 :return: User
3792 3796 """
3793 3797 query = User.query()
3794 3798 query = query.filter(cls.external_id == external_id)
3795 3799 query = query.filter(cls.provider_name == provider_name)
3796 3800 query = query.filter(User.user_id == cls.local_user_id)
3797 3801 return query.first()
3798 3802
3799 3803 @classmethod
3800 3804 def by_local_user_id(cls, local_user_id):
3801 3805 """
3802 3806 Returns all tokens for user
3803 3807
3804 3808 :param local_user_id:
3805 3809 :return: ExternalIdentity
3806 3810 """
3807 3811 query = cls.query()
3808 3812 query = query.filter(cls.local_user_id == local_user_id)
3809 3813 return query
3810 3814
3811 3815
3812 3816 class Integration(Base, BaseModel):
3813 3817 __tablename__ = 'integrations'
3814 3818 __table_args__ = (
3815 3819 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3816 3820 'mysql_charset': 'utf8', 'sqlite_autoincrement': True}
3817 3821 )
3818 3822
3819 3823 integration_id = Column('integration_id', Integer(), primary_key=True)
3820 3824 integration_type = Column('integration_type', String(255))
3821 3825 enabled = Column('enabled', Boolean(), nullable=False)
3822 3826 name = Column('name', String(255), nullable=False)
3823 3827 child_repos_only = Column('child_repos_only', Boolean(), nullable=False,
3824 3828 default=False)
3825 3829
3826 3830 settings = Column(
3827 3831 'settings_json', MutationObj.as_mutable(
3828 3832 JsonType(dialect_map=dict(mysql=UnicodeText(16384)))))
3829 3833 repo_id = Column(
3830 3834 'repo_id', Integer(), ForeignKey('repositories.repo_id'),
3831 3835 nullable=True, unique=None, default=None)
3832 3836 repo = relationship('Repository', lazy='joined')
3833 3837
3834 3838 repo_group_id = Column(
3835 3839 'repo_group_id', Integer(), ForeignKey('groups.group_id'),
3836 3840 nullable=True, unique=None, default=None)
3837 3841 repo_group = relationship('RepoGroup', lazy='joined')
3838 3842
3839 3843 @property
3840 3844 def scope(self):
3841 3845 if self.repo:
3842 3846 return repr(self.repo)
3843 3847 if self.repo_group:
3844 3848 if self.child_repos_only:
3845 3849 return repr(self.repo_group) + ' (child repos only)'
3846 3850 else:
3847 3851 return repr(self.repo_group) + ' (recursive)'
3848 3852 if self.child_repos_only:
3849 3853 return 'root_repos'
3850 3854 return 'global'
3851 3855
3852 3856 def __repr__(self):
3853 3857 return '<Integration(%r, %r)>' % (self.integration_type, self.scope)
3854 3858
3855 3859
3856 3860 class RepoReviewRuleUser(Base, BaseModel):
3857 3861 __tablename__ = 'repo_review_rules_users'
3858 3862 __table_args__ = (
3859 3863 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3860 3864 'mysql_charset': 'utf8', 'sqlite_autoincrement': True,}
3861 3865 )
3862 3866 repo_review_rule_user_id = Column('repo_review_rule_user_id', Integer(), primary_key=True)
3863 3867 repo_review_rule_id = Column("repo_review_rule_id", Integer(), ForeignKey('repo_review_rules.repo_review_rule_id'))
3864 3868 user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=False)
3865 3869 mandatory = Column("mandatory", Boolean(), nullable=False, default=False)
3866 3870 user = relationship('User')
3867 3871
3868 3872 def rule_data(self):
3869 3873 return {
3870 3874 'mandatory': self.mandatory
3871 3875 }
3872 3876
3873 3877
3874 3878 class RepoReviewRuleUserGroup(Base, BaseModel):
3875 3879 __tablename__ = 'repo_review_rules_users_groups'
3876 3880 __table_args__ = (
3877 3881 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3878 3882 'mysql_charset': 'utf8', 'sqlite_autoincrement': True,}
3879 3883 )
3880 3884 repo_review_rule_users_group_id = Column('repo_review_rule_users_group_id', Integer(), primary_key=True)
3881 3885 repo_review_rule_id = Column("repo_review_rule_id", Integer(), ForeignKey('repo_review_rules.repo_review_rule_id'))
3882 3886 users_group_id = Column("users_group_id", Integer(),ForeignKey('users_groups.users_group_id'), nullable=False)
3883 3887 mandatory = Column("mandatory", Boolean(), nullable=False, default=False)
3884 3888 users_group = relationship('UserGroup')
3885 3889
3886 3890 def rule_data(self):
3887 3891 return {
3888 3892 'mandatory': self.mandatory
3889 3893 }
3890 3894
3891 3895
3892 3896 class RepoReviewRule(Base, BaseModel):
3893 3897 __tablename__ = 'repo_review_rules'
3894 3898 __table_args__ = (
3895 3899 {'extend_existing': True, 'mysql_engine': 'InnoDB',
3896 3900 'mysql_charset': 'utf8', 'sqlite_autoincrement': True,}
3897 3901 )
3898 3902
3899 3903 repo_review_rule_id = Column(
3900 3904 'repo_review_rule_id', Integer(), primary_key=True)
3901 3905 repo_id = Column(
3902 3906 "repo_id", Integer(), ForeignKey('repositories.repo_id'))
3903 3907 repo = relationship('Repository', backref='review_rules')
3904 3908
3905 3909 _branch_pattern = Column("branch_pattern", UnicodeText().with_variant(UnicodeText(255), 'mysql'), default=u'*') # glob
3906 3910 _file_pattern = Column("file_pattern", UnicodeText().with_variant(UnicodeText(255), 'mysql'), default=u'*') # glob
3907 3911
3908 3912 use_authors_for_review = Column("use_authors_for_review", Boolean(), nullable=False, default=False)
3909 3913 forbid_author_to_review = Column("forbid_author_to_review", Boolean(), nullable=False, default=False)
3910 3914 forbid_commit_author_to_review = Column("forbid_commit_author_to_review", Boolean(), nullable=False, default=False)
3911 3915 forbid_adding_reviewers = Column("forbid_adding_reviewers", Boolean(), nullable=False, default=False)
3912 3916
3913 3917 rule_users = relationship('RepoReviewRuleUser')
3914 3918 rule_user_groups = relationship('RepoReviewRuleUserGroup')
3915 3919
3916 3920 @hybrid_property
3917 3921 def branch_pattern(self):
3918 3922 return self._branch_pattern or '*'
3919 3923
3920 3924 def _validate_glob(self, value):
3921 3925 re.compile('^' + glob2re(value) + '$')
3922 3926
3923 3927 @branch_pattern.setter
3924 3928 def branch_pattern(self, value):
3925 3929 self._validate_glob(value)
3926 3930 self._branch_pattern = value or '*'
3927 3931
3928 3932 @hybrid_property
3929 3933 def file_pattern(self):
3930 3934 return self._file_pattern or '*'
3931 3935
3932 3936 @file_pattern.setter
3933 3937 def file_pattern(self, value):
3934 3938 self._validate_glob(value)
3935 3939 self._file_pattern = value or '*'
3936 3940
3937 3941 def matches(self, branch, files_changed):
3938 3942 """
3939 3943 Check if this review rule matches a branch/files in a pull request
3940 3944
3941 3945 :param branch: branch name for the commit
3942 3946 :param files_changed: list of file paths changed in the pull request
3943 3947 """
3944 3948
3945 3949 branch = branch or ''
3946 3950 files_changed = files_changed or []
3947 3951
3948 3952 branch_matches = True
3949 3953 if branch:
3950 3954 branch_regex = re.compile('^' + glob2re(self.branch_pattern) + '$')
3951 3955 branch_matches = bool(branch_regex.search(branch))
3952 3956
3953 3957 files_matches = True
3954 3958 if self.file_pattern != '*':
3955 3959 files_matches = False
3956 3960 file_regex = re.compile(glob2re(self.file_pattern))
3957 3961 for filename in files_changed:
3958 3962 if file_regex.search(filename):
3959 3963 files_matches = True
3960 3964 break
3961 3965
3962 3966 return branch_matches and files_matches
3963 3967
3964 3968 @property
3965 3969 def review_users(self):
3966 3970 """ Returns the users which this rule applies to """
3967 3971
3968 3972 users = collections.OrderedDict()
3969 3973
3970 3974 for rule_user in self.rule_users:
3971 3975 if rule_user.user.active:
3972 3976 if rule_user.user not in users:
3973 3977 users[rule_user.user.username] = {
3974 3978 'user': rule_user.user,
3975 3979 'source': 'user',
3976 3980 'source_data': {},
3977 3981 'data': rule_user.rule_data()
3978 3982 }
3979 3983
3980 3984 for rule_user_group in self.rule_user_groups:
3981 3985 source_data = {
3982 3986 'name': rule_user_group.users_group.users_group_name,
3983 3987 'members': len(rule_user_group.users_group.members)
3984 3988 }
3985 3989 for member in rule_user_group.users_group.members:
3986 3990 if member.user.active:
3987 3991 users[member.user.username] = {
3988 3992 'user': member.user,
3989 3993 'source': 'user_group',
3990 3994 'source_data': source_data,
3991 3995 'data': rule_user_group.rule_data()
3992 3996 }
3993 3997
3994 3998 return users
3995 3999
3996 4000 def __repr__(self):
3997 4001 return '<RepoReviewerRule(id=%r, repo=%r)>' % (
3998 4002 self.repo_review_rule_id, self.repo)
3999 4003
4000 4004
4001 4005 class DbMigrateVersion(Base, BaseModel):
4002 4006 __tablename__ = 'db_migrate_version'
4003 4007 __table_args__ = (
4004 4008 {'extend_existing': True, 'mysql_engine': 'InnoDB',
4005 4009 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
4006 4010 )
4007 4011 repository_id = Column('repository_id', String(250), primary_key=True)
4008 4012 repository_path = Column('repository_path', Text)
4009 4013 version = Column('version', Integer)
4010 4014
4011 4015
4012 4016 class DbSession(Base, BaseModel):
4013 4017 __tablename__ = 'db_session'
4014 4018 __table_args__ = (
4015 4019 {'extend_existing': True, 'mysql_engine': 'InnoDB',
4016 4020 'mysql_charset': 'utf8', 'sqlite_autoincrement': True},
4017 4021 )
4018 4022
4019 4023 def __repr__(self):
4020 4024 return '<DB:DbSession({})>'.format(self.id)
4021 4025
4022 4026 id = Column('id', Integer())
4023 4027 namespace = Column('namespace', String(255), primary_key=True)
4024 4028 accessed = Column('accessed', DateTime, nullable=False)
4025 4029 created = Column('created', DateTime, nullable=False)
4026 4030 data = Column('data', PickleType, nullable=False)
@@ -1,1504 +1,1524 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 pyramid.threadlocal import get_current_request
35 35 from sqlalchemy import or_
36 36
37 from rhodecode import events
37 38 from rhodecode.lib import helpers as h, hooks_utils, diffs
38 39 from rhodecode.lib.compat import OrderedDict
39 40 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
40 41 from rhodecode.lib.markup_renderer import (
41 42 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
42 43 from rhodecode.lib.utils import action_logger
43 44 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
44 45 from rhodecode.lib.vcs.backends.base import (
45 46 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
46 47 from rhodecode.lib.vcs.conf import settings as vcs_settings
47 48 from rhodecode.lib.vcs.exceptions import (
48 49 CommitDoesNotExistError, EmptyRepositoryError)
49 50 from rhodecode.model import BaseModel
50 51 from rhodecode.model.changeset_status import ChangesetStatusModel
51 52 from rhodecode.model.comment import CommentsModel
52 53 from rhodecode.model.db import (
53 54 PullRequest, PullRequestReviewers, ChangesetStatus,
54 55 PullRequestVersion, ChangesetComment, Repository)
55 56 from rhodecode.model.meta import Session
56 57 from rhodecode.model.notification import NotificationModel, \
57 58 EmailNotificationModel
58 59 from rhodecode.model.scm import ScmModel
59 60 from rhodecode.model.settings import VcsSettingsModel
60 61
61 62
62 63 log = logging.getLogger(__name__)
63 64
64 65
65 66 # Data structure to hold the response data when updating commits during a pull
66 67 # request update.
67 68 UpdateResponse = namedtuple('UpdateResponse', [
68 69 'executed', 'reason', 'new', 'old', 'changes',
69 70 'source_changed', 'target_changed'])
70 71
71 72
72 73 class PullRequestModel(BaseModel):
73 74
74 75 cls = PullRequest
75 76
76 77 DIFF_CONTEXT = 3
77 78
78 79 MERGE_STATUS_MESSAGES = {
79 80 MergeFailureReason.NONE: lazy_ugettext(
80 81 'This pull request can be automatically merged.'),
81 82 MergeFailureReason.UNKNOWN: lazy_ugettext(
82 83 'This pull request cannot be merged because of an unhandled'
83 84 ' exception.'),
84 85 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
85 86 'This pull request cannot be merged because of merge conflicts.'),
86 87 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
87 88 'This pull request could not be merged because push to target'
88 89 ' failed.'),
89 90 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
90 91 'This pull request cannot be merged because the target is not a'
91 92 ' head.'),
92 93 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
93 94 'This pull request cannot be merged because the source contains'
94 95 ' more branches than the target.'),
95 96 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
96 97 'This pull request cannot be merged because the target has'
97 98 ' multiple heads.'),
98 99 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
99 100 'This pull request cannot be merged because the target repository'
100 101 ' is locked.'),
101 102 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
102 103 'This pull request cannot be merged because the target or the '
103 104 'source reference is missing.'),
104 105 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
105 106 'This pull request cannot be merged because the target '
106 107 'reference is missing.'),
107 108 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
108 109 'This pull request cannot be merged because the source '
109 110 'reference is missing.'),
110 111 MergeFailureReason.SUBREPO_MERGE_FAILED: lazy_ugettext(
111 112 'This pull request cannot be merged because of conflicts related '
112 113 'to sub repositories.'),
113 114 }
114 115
115 116 UPDATE_STATUS_MESSAGES = {
116 117 UpdateFailureReason.NONE: lazy_ugettext(
117 118 'Pull request update successful.'),
118 119 UpdateFailureReason.UNKNOWN: lazy_ugettext(
119 120 'Pull request update failed because of an unknown error.'),
120 121 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
121 122 'No update needed because the source and target have not changed.'),
122 123 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
123 124 'Pull request cannot be updated because the reference type is '
124 125 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
125 126 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
126 127 'This pull request cannot be updated because the target '
127 128 'reference is missing.'),
128 129 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
129 130 'This pull request cannot be updated because the source '
130 131 'reference is missing.'),
131 132 }
132 133
133 134 def __get_pull_request(self, pull_request):
134 135 return self._get_instance((
135 136 PullRequest, PullRequestVersion), pull_request)
136 137
137 138 def _check_perms(self, perms, pull_request, user, api=False):
138 139 if not api:
139 140 return h.HasRepoPermissionAny(*perms)(
140 141 user=user, repo_name=pull_request.target_repo.repo_name)
141 142 else:
142 143 return h.HasRepoPermissionAnyApi(*perms)(
143 144 user=user, repo_name=pull_request.target_repo.repo_name)
144 145
145 146 def check_user_read(self, pull_request, user, api=False):
146 147 _perms = ('repository.admin', 'repository.write', 'repository.read',)
147 148 return self._check_perms(_perms, pull_request, user, api)
148 149
149 150 def check_user_merge(self, pull_request, user, api=False):
150 151 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
151 152 return self._check_perms(_perms, pull_request, user, api)
152 153
153 154 def check_user_update(self, pull_request, user, api=False):
154 155 owner = user.user_id == pull_request.user_id
155 156 return self.check_user_merge(pull_request, user, api) or owner
156 157
157 158 def check_user_delete(self, pull_request, user):
158 159 owner = user.user_id == pull_request.user_id
159 160 _perms = ('repository.admin',)
160 161 return self._check_perms(_perms, pull_request, user) or owner
161 162
162 163 def check_user_change_status(self, pull_request, user, api=False):
163 164 reviewer = user.user_id in [x.user_id for x in
164 165 pull_request.reviewers]
165 166 return self.check_user_update(pull_request, user, api) or reviewer
166 167
167 168 def get(self, pull_request):
168 169 return self.__get_pull_request(pull_request)
169 170
170 171 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
171 172 opened_by=None, order_by=None,
172 173 order_dir='desc'):
173 174 repo = None
174 175 if repo_name:
175 176 repo = self._get_repo(repo_name)
176 177
177 178 q = PullRequest.query()
178 179
179 180 # source or target
180 181 if repo and source:
181 182 q = q.filter(PullRequest.source_repo == repo)
182 183 elif repo:
183 184 q = q.filter(PullRequest.target_repo == repo)
184 185
185 186 # closed,opened
186 187 if statuses:
187 188 q = q.filter(PullRequest.status.in_(statuses))
188 189
189 190 # opened by filter
190 191 if opened_by:
191 192 q = q.filter(PullRequest.user_id.in_(opened_by))
192 193
193 194 if order_by:
194 195 order_map = {
195 196 'name_raw': PullRequest.pull_request_id,
196 197 'title': PullRequest.title,
197 198 'updated_on_raw': PullRequest.updated_on,
198 199 'target_repo': PullRequest.target_repo_id
199 200 }
200 201 if order_dir == 'asc':
201 202 q = q.order_by(order_map[order_by].asc())
202 203 else:
203 204 q = q.order_by(order_map[order_by].desc())
204 205
205 206 return q
206 207
207 208 def count_all(self, repo_name, source=False, statuses=None,
208 209 opened_by=None):
209 210 """
210 211 Count the number of pull requests for a specific repository.
211 212
212 213 :param repo_name: target or source repo
213 214 :param source: boolean flag to specify if repo_name refers to source
214 215 :param statuses: list of pull request statuses
215 216 :param opened_by: author user of the pull request
216 217 :returns: int number of pull requests
217 218 """
218 219 q = self._prepare_get_all_query(
219 220 repo_name, source=source, statuses=statuses, opened_by=opened_by)
220 221
221 222 return q.count()
222 223
223 224 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
224 225 offset=0, length=None, order_by=None, order_dir='desc'):
225 226 """
226 227 Get all pull requests for a specific repository.
227 228
228 229 :param repo_name: target or source repo
229 230 :param source: boolean flag to specify if repo_name refers to source
230 231 :param statuses: list of pull request statuses
231 232 :param opened_by: author user of the pull request
232 233 :param offset: pagination offset
233 234 :param length: length of returned list
234 235 :param order_by: order of the returned list
235 236 :param order_dir: 'asc' or 'desc' ordering direction
236 237 :returns: list of pull requests
237 238 """
238 239 q = self._prepare_get_all_query(
239 240 repo_name, source=source, statuses=statuses, opened_by=opened_by,
240 241 order_by=order_by, order_dir=order_dir)
241 242
242 243 if length:
243 244 pull_requests = q.limit(length).offset(offset).all()
244 245 else:
245 246 pull_requests = q.all()
246 247
247 248 return pull_requests
248 249
249 250 def count_awaiting_review(self, repo_name, source=False, statuses=None,
250 251 opened_by=None):
251 252 """
252 253 Count the number of pull requests for a specific repository that are
253 254 awaiting review.
254 255
255 256 :param repo_name: target or source repo
256 257 :param source: boolean flag to specify if repo_name refers to source
257 258 :param statuses: list of pull request statuses
258 259 :param opened_by: author user of the pull request
259 260 :returns: int number of pull requests
260 261 """
261 262 pull_requests = self.get_awaiting_review(
262 263 repo_name, source=source, statuses=statuses, opened_by=opened_by)
263 264
264 265 return len(pull_requests)
265 266
266 267 def get_awaiting_review(self, repo_name, source=False, statuses=None,
267 268 opened_by=None, offset=0, length=None,
268 269 order_by=None, order_dir='desc'):
269 270 """
270 271 Get all pull requests for a specific repository that are awaiting
271 272 review.
272 273
273 274 :param repo_name: target or source repo
274 275 :param source: boolean flag to specify if repo_name refers to source
275 276 :param statuses: list of pull request statuses
276 277 :param opened_by: author user of the pull request
277 278 :param offset: pagination offset
278 279 :param length: length of returned list
279 280 :param order_by: order of the returned list
280 281 :param order_dir: 'asc' or 'desc' ordering direction
281 282 :returns: list of pull requests
282 283 """
283 284 pull_requests = self.get_all(
284 285 repo_name, source=source, statuses=statuses, opened_by=opened_by,
285 286 order_by=order_by, order_dir=order_dir)
286 287
287 288 _filtered_pull_requests = []
288 289 for pr in pull_requests:
289 290 status = pr.calculated_review_status()
290 291 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
291 292 ChangesetStatus.STATUS_UNDER_REVIEW]:
292 293 _filtered_pull_requests.append(pr)
293 294 if length:
294 295 return _filtered_pull_requests[offset:offset+length]
295 296 else:
296 297 return _filtered_pull_requests
297 298
298 299 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
299 300 opened_by=None, user_id=None):
300 301 """
301 302 Count the number of pull requests for a specific repository that are
302 303 awaiting review from a specific user.
303 304
304 305 :param repo_name: target or source repo
305 306 :param source: boolean flag to specify if repo_name refers to source
306 307 :param statuses: list of pull request statuses
307 308 :param opened_by: author user of the pull request
308 309 :param user_id: reviewer user of the pull request
309 310 :returns: int number of pull requests
310 311 """
311 312 pull_requests = self.get_awaiting_my_review(
312 313 repo_name, source=source, statuses=statuses, opened_by=opened_by,
313 314 user_id=user_id)
314 315
315 316 return len(pull_requests)
316 317
317 318 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
318 319 opened_by=None, user_id=None, offset=0,
319 320 length=None, order_by=None, order_dir='desc'):
320 321 """
321 322 Get all pull requests for a specific repository that are awaiting
322 323 review from a specific user.
323 324
324 325 :param repo_name: target or source repo
325 326 :param source: boolean flag to specify if repo_name refers to source
326 327 :param statuses: list of pull request statuses
327 328 :param opened_by: author user of the pull request
328 329 :param user_id: reviewer user of the pull request
329 330 :param offset: pagination offset
330 331 :param length: length of returned list
331 332 :param order_by: order of the returned list
332 333 :param order_dir: 'asc' or 'desc' ordering direction
333 334 :returns: list of pull requests
334 335 """
335 336 pull_requests = self.get_all(
336 337 repo_name, source=source, statuses=statuses, opened_by=opened_by,
337 338 order_by=order_by, order_dir=order_dir)
338 339
339 340 _my = PullRequestModel().get_not_reviewed(user_id)
340 341 my_participation = []
341 342 for pr in pull_requests:
342 343 if pr in _my:
343 344 my_participation.append(pr)
344 345 _filtered_pull_requests = my_participation
345 346 if length:
346 347 return _filtered_pull_requests[offset:offset+length]
347 348 else:
348 349 return _filtered_pull_requests
349 350
350 351 def get_not_reviewed(self, user_id):
351 352 return [
352 353 x.pull_request for x in PullRequestReviewers.query().filter(
353 354 PullRequestReviewers.user_id == user_id).all()
354 355 ]
355 356
356 357 def _prepare_participating_query(self, user_id=None, statuses=None,
357 358 order_by=None, order_dir='desc'):
358 359 q = PullRequest.query()
359 360 if user_id:
360 361 reviewers_subquery = Session().query(
361 362 PullRequestReviewers.pull_request_id).filter(
362 363 PullRequestReviewers.user_id == user_id).subquery()
363 364 user_filter= or_(
364 365 PullRequest.user_id == user_id,
365 366 PullRequest.pull_request_id.in_(reviewers_subquery)
366 367 )
367 368 q = PullRequest.query().filter(user_filter)
368 369
369 370 # closed,opened
370 371 if statuses:
371 372 q = q.filter(PullRequest.status.in_(statuses))
372 373
373 374 if order_by:
374 375 order_map = {
375 376 'name_raw': PullRequest.pull_request_id,
376 377 'title': PullRequest.title,
377 378 'updated_on_raw': PullRequest.updated_on,
378 379 'target_repo': PullRequest.target_repo_id
379 380 }
380 381 if order_dir == 'asc':
381 382 q = q.order_by(order_map[order_by].asc())
382 383 else:
383 384 q = q.order_by(order_map[order_by].desc())
384 385
385 386 return q
386 387
387 388 def count_im_participating_in(self, user_id=None, statuses=None):
388 389 q = self._prepare_participating_query(user_id, statuses=statuses)
389 390 return q.count()
390 391
391 392 def get_im_participating_in(
392 393 self, user_id=None, statuses=None, offset=0,
393 394 length=None, order_by=None, order_dir='desc'):
394 395 """
395 396 Get all Pull requests that i'm participating in, or i have opened
396 397 """
397 398
398 399 q = self._prepare_participating_query(
399 400 user_id, statuses=statuses, order_by=order_by,
400 401 order_dir=order_dir)
401 402
402 403 if length:
403 404 pull_requests = q.limit(length).offset(offset).all()
404 405 else:
405 406 pull_requests = q.all()
406 407
407 408 return pull_requests
408 409
409 410 def get_versions(self, pull_request):
410 411 """
411 412 returns version of pull request sorted by ID descending
412 413 """
413 414 return PullRequestVersion.query()\
414 415 .filter(PullRequestVersion.pull_request == pull_request)\
415 416 .order_by(PullRequestVersion.pull_request_version_id.asc())\
416 417 .all()
417 418
418 419 def create(self, created_by, source_repo, source_ref, target_repo,
419 420 target_ref, revisions, reviewers, title, description=None,
420 421 reviewer_data=None):
421 422
422 423 created_by_user = self._get_user(created_by)
423 424 source_repo = self._get_repo(source_repo)
424 425 target_repo = self._get_repo(target_repo)
425 426
426 427 pull_request = PullRequest()
427 428 pull_request.source_repo = source_repo
428 429 pull_request.source_ref = source_ref
429 430 pull_request.target_repo = target_repo
430 431 pull_request.target_ref = target_ref
431 432 pull_request.revisions = revisions
432 433 pull_request.title = title
433 434 pull_request.description = description
434 435 pull_request.author = created_by_user
435 436 pull_request.reviewer_data = reviewer_data
436 437
437 438 Session().add(pull_request)
438 439 Session().flush()
439 440
440 441 reviewer_ids = set()
441 442 # members / reviewers
442 443 for reviewer_object in reviewers:
443 444 user_id, reasons, mandatory = reviewer_object
444 445
445 446 user = self._get_user(user_id)
446 447 reviewer_ids.add(user.user_id)
447 448
448 449 reviewer = PullRequestReviewers()
449 450 reviewer.user = user
450 451 reviewer.pull_request = pull_request
451 452 reviewer.reasons = reasons
452 453 reviewer.mandatory = mandatory
453 454 Session().add(reviewer)
454 455
455 456 # Set approval status to "Under Review" for all commits which are
456 457 # part of this pull request.
457 458 ChangesetStatusModel().set_status(
458 459 repo=target_repo,
459 460 status=ChangesetStatus.STATUS_UNDER_REVIEW,
460 461 user=created_by_user,
461 462 pull_request=pull_request
462 463 )
463 464
464 465 self.notify_reviewers(pull_request, reviewer_ids)
465 466 self._trigger_pull_request_hook(
466 467 pull_request, created_by_user, 'create')
467 468
468 469 return pull_request
469 470
470 471 def _trigger_pull_request_hook(self, pull_request, user, action):
471 472 pull_request = self.__get_pull_request(pull_request)
472 473 target_scm = pull_request.target_repo.scm_instance()
473 474 if action == 'create':
474 475 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
475 476 elif action == 'merge':
476 477 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
477 478 elif action == 'close':
478 479 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
479 480 elif action == 'review_status_change':
480 481 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
481 482 elif action == 'update':
482 483 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
483 484 else:
484 485 return
485 486
486 487 trigger_hook(
487 488 username=user.username,
488 489 repo_name=pull_request.target_repo.repo_name,
489 490 repo_alias=target_scm.alias,
490 491 pull_request=pull_request)
491 492
492 493 def _get_commit_ids(self, pull_request):
493 494 """
494 495 Return the commit ids of the merged pull request.
495 496
496 497 This method is not dealing correctly yet with the lack of autoupdates
497 498 nor with the implicit target updates.
498 499 For example: if a commit in the source repo is already in the target it
499 500 will be reported anyways.
500 501 """
501 502 merge_rev = pull_request.merge_rev
502 503 if merge_rev is None:
503 504 raise ValueError('This pull request was not merged yet')
504 505
505 506 commit_ids = list(pull_request.revisions)
506 507 if merge_rev not in commit_ids:
507 508 commit_ids.append(merge_rev)
508 509
509 510 return commit_ids
510 511
511 512 def merge(self, pull_request, user, extras):
512 513 log.debug("Merging pull request %s", pull_request.pull_request_id)
513 514 merge_state = self._merge_pull_request(pull_request, user, extras)
514 515 if merge_state.executed:
515 516 log.debug(
516 517 "Merge was successful, updating the pull request comments.")
517 518 self._comment_and_close_pr(pull_request, user, merge_state)
518 519 self._log_action('user_merged_pull_request', user, pull_request)
519 520 else:
520 521 log.warn("Merge failed, not updating the pull request.")
521 522 return merge_state
522 523
523 524 def _merge_pull_request(self, pull_request, user, extras):
524 525 target_vcs = pull_request.target_repo.scm_instance()
525 526 source_vcs = pull_request.source_repo.scm_instance()
526 527 target_ref = self._refresh_reference(
527 528 pull_request.target_ref_parts, target_vcs)
528 529
529 530 message = _(
530 531 'Merge pull request #%(pr_id)s from '
531 532 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
532 533 'pr_id': pull_request.pull_request_id,
533 534 'source_repo': source_vcs.name,
534 535 'source_ref_name': pull_request.source_ref_parts.name,
535 536 'pr_title': pull_request.title
536 537 }
537 538
538 539 workspace_id = self._workspace_id(pull_request)
539 540 use_rebase = self._use_rebase_for_merging(pull_request)
540 541
541 542 callback_daemon, extras = prepare_callback_daemon(
542 543 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
543 544 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
544 545
545 546 with callback_daemon:
546 547 # TODO: johbo: Implement a clean way to run a config_override
547 548 # for a single call.
548 549 target_vcs.config.set(
549 550 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
550 551 merge_state = target_vcs.merge(
551 552 target_ref, source_vcs, pull_request.source_ref_parts,
552 553 workspace_id, user_name=user.username,
553 554 user_email=user.email, message=message, use_rebase=use_rebase)
554 555 return merge_state
555 556
556 557 def _comment_and_close_pr(self, pull_request, user, merge_state):
557 558 pull_request.merge_rev = merge_state.merge_ref.commit_id
558 559 pull_request.updated_on = datetime.datetime.now()
559 560
560 561 CommentsModel().create(
561 562 text=unicode(_('Pull request merged and closed')),
562 563 repo=pull_request.target_repo.repo_id,
563 564 user=user.user_id,
564 565 pull_request=pull_request.pull_request_id,
565 566 f_path=None,
566 567 line_no=None,
567 568 closing_pr=True
568 569 )
569 570
570 571 Session().add(pull_request)
571 572 Session().flush()
572 573 # TODO: paris: replace invalidation with less radical solution
573 574 ScmModel().mark_for_invalidation(
574 575 pull_request.target_repo.repo_name)
575 576 self._trigger_pull_request_hook(pull_request, user, 'merge')
576 577
577 578 def has_valid_update_type(self, pull_request):
578 579 source_ref_type = pull_request.source_ref_parts.type
579 580 return source_ref_type in ['book', 'branch', 'tag']
580 581
581 582 def update_commits(self, pull_request):
582 583 """
583 584 Get the updated list of commits for the pull request
584 585 and return the new pull request version and the list
585 586 of commits processed by this update action
586 587 """
587 588 pull_request = self.__get_pull_request(pull_request)
588 589 source_ref_type = pull_request.source_ref_parts.type
589 590 source_ref_name = pull_request.source_ref_parts.name
590 591 source_ref_id = pull_request.source_ref_parts.commit_id
591 592
592 593 target_ref_type = pull_request.target_ref_parts.type
593 594 target_ref_name = pull_request.target_ref_parts.name
594 595 target_ref_id = pull_request.target_ref_parts.commit_id
595 596
596 597 if not self.has_valid_update_type(pull_request):
597 598 log.debug(
598 599 "Skipping update of pull request %s due to ref type: %s",
599 600 pull_request, source_ref_type)
600 601 return UpdateResponse(
601 602 executed=False,
602 603 reason=UpdateFailureReason.WRONG_REF_TYPE,
603 604 old=pull_request, new=None, changes=None,
604 605 source_changed=False, target_changed=False)
605 606
606 607 # source repo
607 608 source_repo = pull_request.source_repo.scm_instance()
608 609 try:
609 610 source_commit = source_repo.get_commit(commit_id=source_ref_name)
610 611 except CommitDoesNotExistError:
611 612 return UpdateResponse(
612 613 executed=False,
613 614 reason=UpdateFailureReason.MISSING_SOURCE_REF,
614 615 old=pull_request, new=None, changes=None,
615 616 source_changed=False, target_changed=False)
616 617
617 618 source_changed = source_ref_id != source_commit.raw_id
618 619
619 620 # target repo
620 621 target_repo = pull_request.target_repo.scm_instance()
621 622 try:
622 623 target_commit = target_repo.get_commit(commit_id=target_ref_name)
623 624 except CommitDoesNotExistError:
624 625 return UpdateResponse(
625 626 executed=False,
626 627 reason=UpdateFailureReason.MISSING_TARGET_REF,
627 628 old=pull_request, new=None, changes=None,
628 629 source_changed=False, target_changed=False)
629 630 target_changed = target_ref_id != target_commit.raw_id
630 631
631 632 if not (source_changed or target_changed):
632 633 log.debug("Nothing changed in pull request %s", pull_request)
633 634 return UpdateResponse(
634 635 executed=False,
635 636 reason=UpdateFailureReason.NO_CHANGE,
636 637 old=pull_request, new=None, changes=None,
637 638 source_changed=target_changed, target_changed=source_changed)
638 639
639 640 change_in_found = 'target repo' if target_changed else 'source repo'
640 641 log.debug('Updating pull request because of change in %s detected',
641 642 change_in_found)
642 643
643 644 # Finally there is a need for an update, in case of source change
644 645 # we create a new version, else just an update
645 646 if source_changed:
646 647 pull_request_version = self._create_version_from_snapshot(pull_request)
647 648 self._link_comments_to_version(pull_request_version)
648 649 else:
649 650 try:
650 651 ver = pull_request.versions[-1]
651 652 except IndexError:
652 653 ver = None
653 654
654 655 pull_request.pull_request_version_id = \
655 656 ver.pull_request_version_id if ver else None
656 657 pull_request_version = pull_request
657 658
658 659 try:
659 660 if target_ref_type in ('tag', 'branch', 'book'):
660 661 target_commit = target_repo.get_commit(target_ref_name)
661 662 else:
662 663 target_commit = target_repo.get_commit(target_ref_id)
663 664 except CommitDoesNotExistError:
664 665 return UpdateResponse(
665 666 executed=False,
666 667 reason=UpdateFailureReason.MISSING_TARGET_REF,
667 668 old=pull_request, new=None, changes=None,
668 669 source_changed=source_changed, target_changed=target_changed)
669 670
670 671 # re-compute commit ids
671 672 old_commit_ids = pull_request.revisions
672 673 pre_load = ["author", "branch", "date", "message"]
673 674 commit_ranges = target_repo.compare(
674 675 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
675 676 pre_load=pre_load)
676 677
677 678 ancestor = target_repo.get_common_ancestor(
678 679 target_commit.raw_id, source_commit.raw_id, source_repo)
679 680
680 681 pull_request.source_ref = '%s:%s:%s' % (
681 682 source_ref_type, source_ref_name, source_commit.raw_id)
682 683 pull_request.target_ref = '%s:%s:%s' % (
683 684 target_ref_type, target_ref_name, ancestor)
684 685
685 686 pull_request.revisions = [
686 687 commit.raw_id for commit in reversed(commit_ranges)]
687 688 pull_request.updated_on = datetime.datetime.now()
688 689 Session().add(pull_request)
689 690 new_commit_ids = pull_request.revisions
690 691
691 692 old_diff_data, new_diff_data = self._generate_update_diffs(
692 693 pull_request, pull_request_version)
693 694
694 695 # calculate commit and file changes
695 696 changes = self._calculate_commit_id_changes(
696 697 old_commit_ids, new_commit_ids)
697 698 file_changes = self._calculate_file_changes(
698 699 old_diff_data, new_diff_data)
699 700
700 701 # set comments as outdated if DIFFS changed
701 702 CommentsModel().outdate_comments(
702 703 pull_request, old_diff_data=old_diff_data,
703 704 new_diff_data=new_diff_data)
704 705
705 706 commit_changes = (changes.added or changes.removed)
706 707 file_node_changes = (
707 708 file_changes.added or file_changes.modified or file_changes.removed)
708 709 pr_has_changes = commit_changes or file_node_changes
709 710
710 711 # Add an automatic comment to the pull request, in case
711 712 # anything has changed
712 713 if pr_has_changes:
713 714 update_comment = CommentsModel().create(
714 715 text=self._render_update_message(changes, file_changes),
715 716 repo=pull_request.target_repo,
716 717 user=pull_request.author,
717 718 pull_request=pull_request,
718 719 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
719 720
720 721 # Update status to "Under Review" for added commits
721 722 for commit_id in changes.added:
722 723 ChangesetStatusModel().set_status(
723 724 repo=pull_request.source_repo,
724 725 status=ChangesetStatus.STATUS_UNDER_REVIEW,
725 726 comment=update_comment,
726 727 user=pull_request.author,
727 728 pull_request=pull_request,
728 729 revision=commit_id)
729 730
730 731 log.debug(
731 732 'Updated pull request %s, added_ids: %s, common_ids: %s, '
732 733 'removed_ids: %s', pull_request.pull_request_id,
733 734 changes.added, changes.common, changes.removed)
734 735 log.debug(
735 736 'Updated pull request with the following file changes: %s',
736 737 file_changes)
737 738
738 739 log.info(
739 740 "Updated pull request %s from commit %s to commit %s, "
740 741 "stored new version %s of this pull request.",
741 742 pull_request.pull_request_id, source_ref_id,
742 743 pull_request.source_ref_parts.commit_id,
743 744 pull_request_version.pull_request_version_id)
744 745 Session().commit()
745 746 self._trigger_pull_request_hook(
746 747 pull_request, pull_request.author, 'update')
747 748
748 749 return UpdateResponse(
749 750 executed=True, reason=UpdateFailureReason.NONE,
750 751 old=pull_request, new=pull_request_version, changes=changes,
751 752 source_changed=source_changed, target_changed=target_changed)
752 753
753 754 def _create_version_from_snapshot(self, pull_request):
754 755 version = PullRequestVersion()
755 756 version.title = pull_request.title
756 757 version.description = pull_request.description
757 758 version.status = pull_request.status
758 759 version.created_on = datetime.datetime.now()
759 760 version.updated_on = pull_request.updated_on
760 761 version.user_id = pull_request.user_id
761 762 version.source_repo = pull_request.source_repo
762 763 version.source_ref = pull_request.source_ref
763 764 version.target_repo = pull_request.target_repo
764 765 version.target_ref = pull_request.target_ref
765 766
766 767 version._last_merge_source_rev = pull_request._last_merge_source_rev
767 768 version._last_merge_target_rev = pull_request._last_merge_target_rev
768 769 version._last_merge_status = pull_request._last_merge_status
769 770 version.shadow_merge_ref = pull_request.shadow_merge_ref
770 771 version.merge_rev = pull_request.merge_rev
771 772 version.reviewer_data = pull_request.reviewer_data
772 773
773 774 version.revisions = pull_request.revisions
774 775 version.pull_request = pull_request
775 776 Session().add(version)
776 777 Session().flush()
777 778
778 779 return version
779 780
780 781 def _generate_update_diffs(self, pull_request, pull_request_version):
781 782
782 783 diff_context = (
783 784 self.DIFF_CONTEXT +
784 785 CommentsModel.needed_extra_diff_context())
785 786
786 787 source_repo = pull_request_version.source_repo
787 788 source_ref_id = pull_request_version.source_ref_parts.commit_id
788 789 target_ref_id = pull_request_version.target_ref_parts.commit_id
789 790 old_diff = self._get_diff_from_pr_or_version(
790 791 source_repo, source_ref_id, target_ref_id, context=diff_context)
791 792
792 793 source_repo = pull_request.source_repo
793 794 source_ref_id = pull_request.source_ref_parts.commit_id
794 795 target_ref_id = pull_request.target_ref_parts.commit_id
795 796
796 797 new_diff = self._get_diff_from_pr_or_version(
797 798 source_repo, source_ref_id, target_ref_id, context=diff_context)
798 799
799 800 old_diff_data = diffs.DiffProcessor(old_diff)
800 801 old_diff_data.prepare()
801 802 new_diff_data = diffs.DiffProcessor(new_diff)
802 803 new_diff_data.prepare()
803 804
804 805 return old_diff_data, new_diff_data
805 806
806 807 def _link_comments_to_version(self, pull_request_version):
807 808 """
808 809 Link all unlinked comments of this pull request to the given version.
809 810
810 811 :param pull_request_version: The `PullRequestVersion` to which
811 812 the comments shall be linked.
812 813
813 814 """
814 815 pull_request = pull_request_version.pull_request
815 816 comments = ChangesetComment.query()\
816 817 .filter(
817 818 # TODO: johbo: Should we query for the repo at all here?
818 819 # Pending decision on how comments of PRs are to be related
819 820 # to either the source repo, the target repo or no repo at all.
820 821 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
821 822 ChangesetComment.pull_request == pull_request,
822 823 ChangesetComment.pull_request_version == None)\
823 824 .order_by(ChangesetComment.comment_id.asc())
824 825
825 826 # TODO: johbo: Find out why this breaks if it is done in a bulk
826 827 # operation.
827 828 for comment in comments:
828 829 comment.pull_request_version_id = (
829 830 pull_request_version.pull_request_version_id)
830 831 Session().add(comment)
831 832
832 833 def _calculate_commit_id_changes(self, old_ids, new_ids):
833 834 added = [x for x in new_ids if x not in old_ids]
834 835 common = [x for x in new_ids if x in old_ids]
835 836 removed = [x for x in old_ids if x not in new_ids]
836 837 total = new_ids
837 838 return ChangeTuple(added, common, removed, total)
838 839
839 840 def _calculate_file_changes(self, old_diff_data, new_diff_data):
840 841
841 842 old_files = OrderedDict()
842 843 for diff_data in old_diff_data.parsed_diff:
843 844 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
844 845
845 846 added_files = []
846 847 modified_files = []
847 848 removed_files = []
848 849 for diff_data in new_diff_data.parsed_diff:
849 850 new_filename = diff_data['filename']
850 851 new_hash = md5_safe(diff_data['raw_diff'])
851 852
852 853 old_hash = old_files.get(new_filename)
853 854 if not old_hash:
854 855 # file is not present in old diff, means it's added
855 856 added_files.append(new_filename)
856 857 else:
857 858 if new_hash != old_hash:
858 859 modified_files.append(new_filename)
859 860 # now remove a file from old, since we have seen it already
860 861 del old_files[new_filename]
861 862
862 863 # removed files is when there are present in old, but not in NEW,
863 864 # since we remove old files that are present in new diff, left-overs
864 865 # if any should be the removed files
865 866 removed_files.extend(old_files.keys())
866 867
867 868 return FileChangeTuple(added_files, modified_files, removed_files)
868 869
869 870 def _render_update_message(self, changes, file_changes):
870 871 """
871 872 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
872 873 so it's always looking the same disregarding on which default
873 874 renderer system is using.
874 875
875 876 :param changes: changes named tuple
876 877 :param file_changes: file changes named tuple
877 878
878 879 """
879 880 new_status = ChangesetStatus.get_status_lbl(
880 881 ChangesetStatus.STATUS_UNDER_REVIEW)
881 882
882 883 changed_files = (
883 884 file_changes.added + file_changes.modified + file_changes.removed)
884 885
885 886 params = {
886 887 'under_review_label': new_status,
887 888 'added_commits': changes.added,
888 889 'removed_commits': changes.removed,
889 890 'changed_files': changed_files,
890 891 'added_files': file_changes.added,
891 892 'modified_files': file_changes.modified,
892 893 'removed_files': file_changes.removed,
893 894 }
894 895 renderer = RstTemplateRenderer()
895 896 return renderer.render('pull_request_update.mako', **params)
896 897
897 898 def edit(self, pull_request, title, description):
898 899 pull_request = self.__get_pull_request(pull_request)
899 900 if pull_request.is_closed():
900 901 raise ValueError('This pull request is closed')
901 902 if title:
902 903 pull_request.title = title
903 904 pull_request.description = description
904 905 pull_request.updated_on = datetime.datetime.now()
905 906 Session().add(pull_request)
906 907
907 908 def update_reviewers(self, pull_request, reviewer_data):
908 909 """
909 910 Update the reviewers in the pull request
910 911
911 912 :param pull_request: the pr to update
912 913 :param reviewer_data: list of tuples
913 914 [(user, ['reason1', 'reason2'], mandatory_flag)]
914 915 """
915 916
916 917 reviewers = {}
917 918 for user_id, reasons, mandatory in reviewer_data:
918 919 if isinstance(user_id, (int, basestring)):
919 920 user_id = self._get_user(user_id).user_id
920 921 reviewers[user_id] = {
921 922 'reasons': reasons, 'mandatory': mandatory}
922 923
923 924 reviewers_ids = set(reviewers.keys())
924 925 pull_request = self.__get_pull_request(pull_request)
925 926 current_reviewers = PullRequestReviewers.query()\
926 927 .filter(PullRequestReviewers.pull_request ==
927 928 pull_request).all()
928 929 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
929 930
930 931 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
931 932 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
932 933
933 934 log.debug("Adding %s reviewers", ids_to_add)
934 935 log.debug("Removing %s reviewers", ids_to_remove)
935 936 changed = False
936 937 for uid in ids_to_add:
937 938 changed = True
938 939 _usr = self._get_user(uid)
939 940 reviewer = PullRequestReviewers()
940 941 reviewer.user = _usr
941 942 reviewer.pull_request = pull_request
942 943 reviewer.reasons = reviewers[uid]['reasons']
943 944 # NOTE(marcink): mandatory shouldn't be changed now
944 945 #reviewer.mandatory = reviewers[uid]['reasons']
945 946 Session().add(reviewer)
946 947
947 948 for uid in ids_to_remove:
948 949 changed = True
949 950 reviewers = PullRequestReviewers.query()\
950 951 .filter(PullRequestReviewers.user_id == uid,
951 952 PullRequestReviewers.pull_request == pull_request)\
952 953 .all()
953 954 # use .all() in case we accidentally added the same person twice
954 955 # this CAN happen due to the lack of DB checks
955 956 for obj in reviewers:
956 957 Session().delete(obj)
957 958
958 959 if changed:
959 960 pull_request.updated_on = datetime.datetime.now()
960 961 Session().add(pull_request)
961 962
962 963 self.notify_reviewers(pull_request, ids_to_add)
963 964 return ids_to_add, ids_to_remove
964 965
965 966 def get_url(self, pull_request, request=None, permalink=False):
966 967 if not request:
967 968 request = get_current_request()
968 969
969 970 if permalink:
970 971 return request.route_url(
971 972 'pull_requests_global',
972 973 pull_request_id=pull_request.pull_request_id,)
973 974 else:
974 975 return request.route_url(
975 976 'pullrequest_show',
976 977 repo_name=safe_str(pull_request.target_repo.repo_name),
977 978 pull_request_id=pull_request.pull_request_id,)
978 979
979 980 def get_shadow_clone_url(self, pull_request):
980 981 """
981 982 Returns qualified url pointing to the shadow repository. If this pull
982 983 request is closed there is no shadow repository and ``None`` will be
983 984 returned.
984 985 """
985 986 if pull_request.is_closed():
986 987 return None
987 988 else:
988 989 pr_url = urllib.unquote(self.get_url(pull_request))
989 990 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
990 991
991 992 def notify_reviewers(self, pull_request, reviewers_ids):
992 993 # notification to reviewers
993 994 if not reviewers_ids:
994 995 return
995 996
996 997 pull_request_obj = pull_request
997 998 # get the current participants of this pull request
998 999 recipients = reviewers_ids
999 1000 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1000 1001
1001 1002 pr_source_repo = pull_request_obj.source_repo
1002 1003 pr_target_repo = pull_request_obj.target_repo
1003 1004
1004 1005 pr_url = h.url(
1005 1006 'pullrequest_show',
1006 1007 repo_name=pr_target_repo.repo_name,
1007 1008 pull_request_id=pull_request_obj.pull_request_id,
1008 1009 qualified=True,)
1009 1010
1010 1011 # set some variables for email notification
1011 1012 pr_target_repo_url = h.route_url(
1012 1013 'repo_summary', repo_name=pr_target_repo.repo_name)
1013 1014
1014 1015 pr_source_repo_url = h.route_url(
1015 1016 'repo_summary', repo_name=pr_source_repo.repo_name)
1016 1017
1017 1018 # pull request specifics
1018 1019 pull_request_commits = [
1019 1020 (x.raw_id, x.message)
1020 1021 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1021 1022
1022 1023 kwargs = {
1023 1024 'user': pull_request.author,
1024 1025 'pull_request': pull_request_obj,
1025 1026 'pull_request_commits': pull_request_commits,
1026 1027
1027 1028 'pull_request_target_repo': pr_target_repo,
1028 1029 'pull_request_target_repo_url': pr_target_repo_url,
1029 1030
1030 1031 'pull_request_source_repo': pr_source_repo,
1031 1032 'pull_request_source_repo_url': pr_source_repo_url,
1032 1033
1033 1034 'pull_request_url': pr_url,
1034 1035 }
1035 1036
1036 1037 # pre-generate the subject for notification itself
1037 1038 (subject,
1038 1039 _h, _e, # we don't care about those
1039 1040 body_plaintext) = EmailNotificationModel().render_email(
1040 1041 notification_type, **kwargs)
1041 1042
1042 1043 # create notification objects, and emails
1043 1044 NotificationModel().create(
1044 1045 created_by=pull_request.author,
1045 1046 notification_subject=subject,
1046 1047 notification_body=body_plaintext,
1047 1048 notification_type=notification_type,
1048 1049 recipients=recipients,
1049 1050 email_kwargs=kwargs,
1050 1051 )
1051 1052
1052 1053 def delete(self, pull_request):
1053 1054 pull_request = self.__get_pull_request(pull_request)
1054 1055 self._cleanup_merge_workspace(pull_request)
1055 1056 Session().delete(pull_request)
1056 1057
1057 1058 def close_pull_request(self, pull_request, user):
1058 1059 pull_request = self.__get_pull_request(pull_request)
1059 1060 self._cleanup_merge_workspace(pull_request)
1060 1061 pull_request.status = PullRequest.STATUS_CLOSED
1061 1062 pull_request.updated_on = datetime.datetime.now()
1062 1063 Session().add(pull_request)
1063 1064 self._trigger_pull_request_hook(
1064 1065 pull_request, pull_request.author, 'close')
1065 1066 self._log_action('user_closed_pull_request', user, pull_request)
1066 1067
1067 def close_pull_request_with_comment(self, pull_request, user, repo,
1068 message=None):
1069 status = ChangesetStatus.STATUS_REJECTED
1068 def close_pull_request_with_comment(
1069 self, pull_request, user, repo, message=None):
1070
1071 pull_request_review_status = pull_request.calculated_review_status()
1070 1072
1071 if not message:
1072 message = (
1073 _('Status change %(transition_icon)s %(status)s') % {
1074 'transition_icon': '>',
1075 'status': ChangesetStatus.get_status_lbl(status)})
1073 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1074 # approved only if we have voting consent
1075 status = ChangesetStatus.STATUS_APPROVED
1076 else:
1077 status = ChangesetStatus.STATUS_REJECTED
1078 status_lbl = ChangesetStatus.get_status_lbl(status)
1076 1079
1077 internal_message = _('Closing with') + ' ' + message
1080 default_message = (
1081 _('Closing with status change {transition_icon} {status}.')
1082 ).format(transition_icon='>', status=status_lbl)
1083 text = message or default_message
1078 1084
1079 comm = CommentsModel().create(
1080 text=internal_message,
1085 # create a comment, and link it to new status
1086 comment = CommentsModel().create(
1087 text=text,
1081 1088 repo=repo.repo_id,
1082 1089 user=user.user_id,
1083 1090 pull_request=pull_request.pull_request_id,
1084 f_path=None,
1085 line_no=None,
1086 status_change=ChangesetStatus.get_status_lbl(status),
1091 status_change=status_lbl,
1087 1092 status_change_type=status,
1088 1093 closing_pr=True
1089 1094 )
1090 1095
1096 # calculate old status before we change it
1097 old_calculated_status = pull_request.calculated_review_status()
1091 1098 ChangesetStatusModel().set_status(
1092 1099 repo.repo_id,
1093 1100 status,
1094 1101 user.user_id,
1095 comm,
1102 comment=comment,
1096 1103 pull_request=pull_request.pull_request_id
1097 1104 )
1105
1098 1106 Session().flush()
1107 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1108 # we now calculate the status of pull request again, and based on that
1109 # calculation trigger status change. This might happen in cases
1110 # that non-reviewer admin closes a pr, which means his vote doesn't
1111 # change the status, while if he's a reviewer this might change it.
1112 calculated_status = pull_request.calculated_review_status()
1113 if old_calculated_status != calculated_status:
1114 self._trigger_pull_request_hook(
1115 pull_request, user, 'review_status_change')
1099 1116
1117 # finally close the PR
1100 1118 PullRequestModel().close_pull_request(
1101 1119 pull_request.pull_request_id, user)
1102 1120
1121 return comment, status
1122
1103 1123 def merge_status(self, pull_request):
1104 1124 if not self._is_merge_enabled(pull_request):
1105 1125 return False, _('Server-side pull request merging is disabled.')
1106 1126 if pull_request.is_closed():
1107 1127 return False, _('This pull request is closed.')
1108 1128 merge_possible, msg = self._check_repo_requirements(
1109 1129 target=pull_request.target_repo, source=pull_request.source_repo)
1110 1130 if not merge_possible:
1111 1131 return merge_possible, msg
1112 1132
1113 1133 try:
1114 1134 resp = self._try_merge(pull_request)
1115 1135 log.debug("Merge response: %s", resp)
1116 1136 status = resp.possible, self.merge_status_message(
1117 1137 resp.failure_reason)
1118 1138 except NotImplementedError:
1119 1139 status = False, _('Pull request merging is not supported.')
1120 1140
1121 1141 return status
1122 1142
1123 1143 def _check_repo_requirements(self, target, source):
1124 1144 """
1125 1145 Check if `target` and `source` have compatible requirements.
1126 1146
1127 1147 Currently this is just checking for largefiles.
1128 1148 """
1129 1149 target_has_largefiles = self._has_largefiles(target)
1130 1150 source_has_largefiles = self._has_largefiles(source)
1131 1151 merge_possible = True
1132 1152 message = u''
1133 1153
1134 1154 if target_has_largefiles != source_has_largefiles:
1135 1155 merge_possible = False
1136 1156 if source_has_largefiles:
1137 1157 message = _(
1138 1158 'Target repository large files support is disabled.')
1139 1159 else:
1140 1160 message = _(
1141 1161 'Source repository large files support is disabled.')
1142 1162
1143 1163 return merge_possible, message
1144 1164
1145 1165 def _has_largefiles(self, repo):
1146 1166 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1147 1167 'extensions', 'largefiles')
1148 1168 return largefiles_ui and largefiles_ui[0].active
1149 1169
1150 1170 def _try_merge(self, pull_request):
1151 1171 """
1152 1172 Try to merge the pull request and return the merge status.
1153 1173 """
1154 1174 log.debug(
1155 1175 "Trying out if the pull request %s can be merged.",
1156 1176 pull_request.pull_request_id)
1157 1177 target_vcs = pull_request.target_repo.scm_instance()
1158 1178
1159 1179 # Refresh the target reference.
1160 1180 try:
1161 1181 target_ref = self._refresh_reference(
1162 1182 pull_request.target_ref_parts, target_vcs)
1163 1183 except CommitDoesNotExistError:
1164 1184 merge_state = MergeResponse(
1165 1185 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
1166 1186 return merge_state
1167 1187
1168 1188 target_locked = pull_request.target_repo.locked
1169 1189 if target_locked and target_locked[0]:
1170 1190 log.debug("The target repository is locked.")
1171 1191 merge_state = MergeResponse(
1172 1192 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
1173 1193 elif self._needs_merge_state_refresh(pull_request, target_ref):
1174 1194 log.debug("Refreshing the merge status of the repository.")
1175 1195 merge_state = self._refresh_merge_state(
1176 1196 pull_request, target_vcs, target_ref)
1177 1197 else:
1178 1198 possible = pull_request.\
1179 1199 _last_merge_status == MergeFailureReason.NONE
1180 1200 merge_state = MergeResponse(
1181 1201 possible, False, None, pull_request._last_merge_status)
1182 1202
1183 1203 return merge_state
1184 1204
1185 1205 def _refresh_reference(self, reference, vcs_repository):
1186 1206 if reference.type in ('branch', 'book'):
1187 1207 name_or_id = reference.name
1188 1208 else:
1189 1209 name_or_id = reference.commit_id
1190 1210 refreshed_commit = vcs_repository.get_commit(name_or_id)
1191 1211 refreshed_reference = Reference(
1192 1212 reference.type, reference.name, refreshed_commit.raw_id)
1193 1213 return refreshed_reference
1194 1214
1195 1215 def _needs_merge_state_refresh(self, pull_request, target_reference):
1196 1216 return not(
1197 1217 pull_request.revisions and
1198 1218 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1199 1219 target_reference.commit_id == pull_request._last_merge_target_rev)
1200 1220
1201 1221 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1202 1222 workspace_id = self._workspace_id(pull_request)
1203 1223 source_vcs = pull_request.source_repo.scm_instance()
1204 1224 use_rebase = self._use_rebase_for_merging(pull_request)
1205 1225 merge_state = target_vcs.merge(
1206 1226 target_reference, source_vcs, pull_request.source_ref_parts,
1207 1227 workspace_id, dry_run=True, use_rebase=use_rebase)
1208 1228
1209 1229 # Do not store the response if there was an unknown error.
1210 1230 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1211 1231 pull_request._last_merge_source_rev = \
1212 1232 pull_request.source_ref_parts.commit_id
1213 1233 pull_request._last_merge_target_rev = target_reference.commit_id
1214 1234 pull_request._last_merge_status = merge_state.failure_reason
1215 1235 pull_request.shadow_merge_ref = merge_state.merge_ref
1216 1236 Session().add(pull_request)
1217 1237 Session().commit()
1218 1238
1219 1239 return merge_state
1220 1240
1221 1241 def _workspace_id(self, pull_request):
1222 1242 workspace_id = 'pr-%s' % pull_request.pull_request_id
1223 1243 return workspace_id
1224 1244
1225 1245 def merge_status_message(self, status_code):
1226 1246 """
1227 1247 Return a human friendly error message for the given merge status code.
1228 1248 """
1229 1249 return self.MERGE_STATUS_MESSAGES[status_code]
1230 1250
1231 1251 def generate_repo_data(self, repo, commit_id=None, branch=None,
1232 1252 bookmark=None):
1233 1253 all_refs, selected_ref = \
1234 1254 self._get_repo_pullrequest_sources(
1235 1255 repo.scm_instance(), commit_id=commit_id,
1236 1256 branch=branch, bookmark=bookmark)
1237 1257
1238 1258 refs_select2 = []
1239 1259 for element in all_refs:
1240 1260 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1241 1261 refs_select2.append({'text': element[1], 'children': children})
1242 1262
1243 1263 return {
1244 1264 'user': {
1245 1265 'user_id': repo.user.user_id,
1246 1266 'username': repo.user.username,
1247 1267 'firstname': repo.user.firstname,
1248 1268 'lastname': repo.user.lastname,
1249 1269 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1250 1270 },
1251 1271 'description': h.chop_at_smart(repo.description, '\n'),
1252 1272 'refs': {
1253 1273 'all_refs': all_refs,
1254 1274 'selected_ref': selected_ref,
1255 1275 'select2_refs': refs_select2
1256 1276 }
1257 1277 }
1258 1278
1259 1279 def generate_pullrequest_title(self, source, source_ref, target):
1260 1280 return u'{source}#{at_ref} to {target}'.format(
1261 1281 source=source,
1262 1282 at_ref=source_ref,
1263 1283 target=target,
1264 1284 )
1265 1285
1266 1286 def _cleanup_merge_workspace(self, pull_request):
1267 1287 # Merging related cleanup
1268 1288 target_scm = pull_request.target_repo.scm_instance()
1269 1289 workspace_id = 'pr-%s' % pull_request.pull_request_id
1270 1290
1271 1291 try:
1272 1292 target_scm.cleanup_merge_workspace(workspace_id)
1273 1293 except NotImplementedError:
1274 1294 pass
1275 1295
1276 1296 def _get_repo_pullrequest_sources(
1277 1297 self, repo, commit_id=None, branch=None, bookmark=None):
1278 1298 """
1279 1299 Return a structure with repo's interesting commits, suitable for
1280 1300 the selectors in pullrequest controller
1281 1301
1282 1302 :param commit_id: a commit that must be in the list somehow
1283 1303 and selected by default
1284 1304 :param branch: a branch that must be in the list and selected
1285 1305 by default - even if closed
1286 1306 :param bookmark: a bookmark that must be in the list and selected
1287 1307 """
1288 1308
1289 1309 commit_id = safe_str(commit_id) if commit_id else None
1290 1310 branch = safe_str(branch) if branch else None
1291 1311 bookmark = safe_str(bookmark) if bookmark else None
1292 1312
1293 1313 selected = None
1294 1314
1295 1315 # order matters: first source that has commit_id in it will be selected
1296 1316 sources = []
1297 1317 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1298 1318 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1299 1319
1300 1320 if commit_id:
1301 1321 ref_commit = (h.short_id(commit_id), commit_id)
1302 1322 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1303 1323
1304 1324 sources.append(
1305 1325 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1306 1326 )
1307 1327
1308 1328 groups = []
1309 1329 for group_key, ref_list, group_name, match in sources:
1310 1330 group_refs = []
1311 1331 for ref_name, ref_id in ref_list:
1312 1332 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1313 1333 group_refs.append((ref_key, ref_name))
1314 1334
1315 1335 if not selected:
1316 1336 if set([commit_id, match]) & set([ref_id, ref_name]):
1317 1337 selected = ref_key
1318 1338
1319 1339 if group_refs:
1320 1340 groups.append((group_refs, group_name))
1321 1341
1322 1342 if not selected:
1323 1343 ref = commit_id or branch or bookmark
1324 1344 if ref:
1325 1345 raise CommitDoesNotExistError(
1326 1346 'No commit refs could be found matching: %s' % ref)
1327 1347 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1328 1348 selected = 'branch:%s:%s' % (
1329 1349 repo.DEFAULT_BRANCH_NAME,
1330 1350 repo.branches[repo.DEFAULT_BRANCH_NAME]
1331 1351 )
1332 1352 elif repo.commit_ids:
1333 1353 rev = repo.commit_ids[0]
1334 1354 selected = 'rev:%s:%s' % (rev, rev)
1335 1355 else:
1336 1356 raise EmptyRepositoryError()
1337 1357 return groups, selected
1338 1358
1339 1359 def get_diff(self, source_repo, source_ref_id, target_ref_id, context=DIFF_CONTEXT):
1340 1360 return self._get_diff_from_pr_or_version(
1341 1361 source_repo, source_ref_id, target_ref_id, context=context)
1342 1362
1343 1363 def _get_diff_from_pr_or_version(
1344 1364 self, source_repo, source_ref_id, target_ref_id, context):
1345 1365 target_commit = source_repo.get_commit(
1346 1366 commit_id=safe_str(target_ref_id))
1347 1367 source_commit = source_repo.get_commit(
1348 1368 commit_id=safe_str(source_ref_id))
1349 1369 if isinstance(source_repo, Repository):
1350 1370 vcs_repo = source_repo.scm_instance()
1351 1371 else:
1352 1372 vcs_repo = source_repo
1353 1373
1354 1374 # TODO: johbo: In the context of an update, we cannot reach
1355 1375 # the old commit anymore with our normal mechanisms. It needs
1356 1376 # some sort of special support in the vcs layer to avoid this
1357 1377 # workaround.
1358 1378 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1359 1379 vcs_repo.alias == 'git'):
1360 1380 source_commit.raw_id = safe_str(source_ref_id)
1361 1381
1362 1382 log.debug('calculating diff between '
1363 1383 'source_ref:%s and target_ref:%s for repo `%s`',
1364 1384 target_ref_id, source_ref_id,
1365 1385 safe_unicode(vcs_repo.path))
1366 1386
1367 1387 vcs_diff = vcs_repo.get_diff(
1368 1388 commit1=target_commit, commit2=source_commit, context=context)
1369 1389 return vcs_diff
1370 1390
1371 1391 def _is_merge_enabled(self, pull_request):
1372 1392 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1373 1393 settings = settings_model.get_general_settings()
1374 1394 return settings.get('rhodecode_pr_merge_enabled', False)
1375 1395
1376 1396 def _use_rebase_for_merging(self, pull_request):
1377 1397 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1378 1398 settings = settings_model.get_general_settings()
1379 1399 return settings.get('rhodecode_hg_use_rebase_for_merging', False)
1380 1400
1381 1401 def _log_action(self, action, user, pull_request):
1382 1402 action_logger(
1383 1403 user,
1384 1404 '{action}:{pr_id}'.format(
1385 1405 action=action, pr_id=pull_request.pull_request_id),
1386 1406 pull_request.target_repo)
1387 1407
1388 1408 def get_reviewer_functions(self):
1389 1409 """
1390 1410 Fetches functions for validation and fetching default reviewers.
1391 1411 If available we use the EE package, else we fallback to CE
1392 1412 package functions
1393 1413 """
1394 1414 try:
1395 1415 from rc_reviewers.utils import get_default_reviewers_data
1396 1416 from rc_reviewers.utils import validate_default_reviewers
1397 1417 except ImportError:
1398 1418 from rhodecode.apps.repository.utils import \
1399 1419 get_default_reviewers_data
1400 1420 from rhodecode.apps.repository.utils import \
1401 1421 validate_default_reviewers
1402 1422
1403 1423 return get_default_reviewers_data, validate_default_reviewers
1404 1424
1405 1425
1406 1426 class MergeCheck(object):
1407 1427 """
1408 1428 Perform Merge Checks and returns a check object which stores information
1409 1429 about merge errors, and merge conditions
1410 1430 """
1411 1431 TODO_CHECK = 'todo'
1412 1432 PERM_CHECK = 'perm'
1413 1433 REVIEW_CHECK = 'review'
1414 1434 MERGE_CHECK = 'merge'
1415 1435
1416 1436 def __init__(self):
1417 1437 self.review_status = None
1418 1438 self.merge_possible = None
1419 1439 self.merge_msg = ''
1420 1440 self.failed = None
1421 1441 self.errors = []
1422 1442 self.error_details = OrderedDict()
1423 1443
1424 1444 def push_error(self, error_type, message, error_key, details):
1425 1445 self.failed = True
1426 1446 self.errors.append([error_type, message])
1427 1447 self.error_details[error_key] = dict(
1428 1448 details=details,
1429 1449 error_type=error_type,
1430 1450 message=message
1431 1451 )
1432 1452
1433 1453 @classmethod
1434 1454 def validate(cls, pull_request, user, fail_early=False, translator=None):
1435 1455 # if migrated to pyramid...
1436 1456 # _ = lambda: translator or _ # use passed in translator if any
1437 1457
1438 1458 merge_check = cls()
1439 1459
1440 1460 # permissions to merge
1441 1461 user_allowed_to_merge = PullRequestModel().check_user_merge(
1442 1462 pull_request, user)
1443 1463 if not user_allowed_to_merge:
1444 1464 log.debug("MergeCheck: cannot merge, approval is pending.")
1445 1465
1446 1466 msg = _('User `{}` not allowed to perform merge.').format(user.username)
1447 1467 merge_check.push_error('error', msg, cls.PERM_CHECK, user.username)
1448 1468 if fail_early:
1449 1469 return merge_check
1450 1470
1451 1471 # review status, must be always present
1452 1472 review_status = pull_request.calculated_review_status()
1453 1473 merge_check.review_status = review_status
1454 1474
1455 1475 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1456 1476 if not status_approved:
1457 1477 log.debug("MergeCheck: cannot merge, approval is pending.")
1458 1478
1459 1479 msg = _('Pull request reviewer approval is pending.')
1460 1480
1461 1481 merge_check.push_error(
1462 1482 'warning', msg, cls.REVIEW_CHECK, review_status)
1463 1483
1464 1484 if fail_early:
1465 1485 return merge_check
1466 1486
1467 1487 # left over TODOs
1468 1488 todos = CommentsModel().get_unresolved_todos(pull_request)
1469 1489 if todos:
1470 1490 log.debug("MergeCheck: cannot merge, {} "
1471 1491 "unresolved todos left.".format(len(todos)))
1472 1492
1473 1493 if len(todos) == 1:
1474 1494 msg = _('Cannot merge, {} TODO still not resolved.').format(
1475 1495 len(todos))
1476 1496 else:
1477 1497 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1478 1498 len(todos))
1479 1499
1480 1500 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1481 1501
1482 1502 if fail_early:
1483 1503 return merge_check
1484 1504
1485 1505 # merge possible
1486 1506 merge_status, msg = PullRequestModel().merge_status(pull_request)
1487 1507 merge_check.merge_possible = merge_status
1488 1508 merge_check.merge_msg = msg
1489 1509 if not merge_status:
1490 1510 log.debug(
1491 1511 "MergeCheck: cannot merge, pull request merge not possible.")
1492 1512 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1493 1513
1494 1514 if fail_early:
1495 1515 return merge_check
1496 1516
1497 1517 return merge_check
1498 1518
1499 1519
1500 1520 ChangeTuple = namedtuple('ChangeTuple',
1501 1521 ['added', 'common', 'removed', 'total'])
1502 1522
1503 1523 FileChangeTuple = namedtuple('FileChangeTuple',
1504 1524 ['added', 'modified', 'removed'])
@@ -1,138 +1,139 b''
1 1
2 2 /******************************************************************************
3 3 * *
4 4 * DO NOT CHANGE THIS FILE MANUALLY *
5 5 * *
6 6 * *
7 7 * This file is automatically generated when the app starts up with *
8 8 * generate_js_files = true *
9 9 * *
10 10 * To add a route here pass jsroute=True to the route definition in the app *
11 11 * *
12 12 ******************************************************************************/
13 13 function registerRCRoutes() {
14 14 // routes registration
15 15 pyroutes.register('new_repo', '/_admin/create_repository', []);
16 16 pyroutes.register('edit_user', '/_admin/users/%(user_id)s/edit', ['user_id']);
17 17 pyroutes.register('edit_user_group_members', '/_admin/user_groups/%(user_group_id)s/edit/members', ['user_group_id']);
18 18 pyroutes.register('gists', '/_admin/gists', []);
19 19 pyroutes.register('new_gist', '/_admin/gists/new', []);
20 20 pyroutes.register('toggle_following', '/_admin/toggle_following', []);
21 21 pyroutes.register('changeset_home', '/%(repo_name)s/changeset/%(revision)s', ['repo_name', 'revision']);
22 22 pyroutes.register('changeset_comment', '/%(repo_name)s/changeset/%(revision)s/comment', ['repo_name', 'revision']);
23 23 pyroutes.register('changeset_comment_preview', '/%(repo_name)s/changeset/comment/preview', ['repo_name']);
24 24 pyroutes.register('changeset_comment_delete', '/%(repo_name)s/changeset/comment/%(comment_id)s/delete', ['repo_name', 'comment_id']);
25 25 pyroutes.register('changeset_info', '/%(repo_name)s/changeset_info/%(revision)s', ['repo_name', 'revision']);
26 26 pyroutes.register('compare_url', '/%(repo_name)s/compare/%(source_ref_type)s@%(source_ref)s...%(target_ref_type)s@%(target_ref)s', ['repo_name', 'source_ref_type', 'source_ref', 'target_ref_type', 'target_ref']);
27 27 pyroutes.register('pullrequest_home', '/%(repo_name)s/pull-request/new', ['repo_name']);
28 28 pyroutes.register('pullrequest', '/%(repo_name)s/pull-request/new', ['repo_name']);
29 29 pyroutes.register('pullrequest_repo_refs', '/%(repo_name)s/pull-request/refs/%(target_repo_name)s', ['repo_name', 'target_repo_name']);
30 30 pyroutes.register('pullrequest_repo_destinations', '/%(repo_name)s/pull-request/repo-destinations', ['repo_name']);
31 31 pyroutes.register('pullrequest_show', '/%(repo_name)s/pull-request/%(pull_request_id)s', ['repo_name', 'pull_request_id']);
32 32 pyroutes.register('pullrequest_update', '/%(repo_name)s/pull-request/%(pull_request_id)s', ['repo_name', 'pull_request_id']);
33 33 pyroutes.register('pullrequest_comment', '/%(repo_name)s/pull-request-comment/%(pull_request_id)s', ['repo_name', 'pull_request_id']);
34 34 pyroutes.register('pullrequest_comment_delete', '/%(repo_name)s/pull-request-comment/%(comment_id)s/delete', ['repo_name', 'comment_id']);
35 35 pyroutes.register('changelog_home', '/%(repo_name)s/changelog', ['repo_name']);
36 36 pyroutes.register('changelog_file_home', '/%(repo_name)s/changelog/%(revision)s/%(f_path)s', ['repo_name', 'revision', 'f_path']);
37 37 pyroutes.register('changelog_elements', '/%(repo_name)s/changelog_details', ['repo_name']);
38 38 pyroutes.register('files_home', '/%(repo_name)s/files/%(revision)s/%(f_path)s', ['repo_name', 'revision', 'f_path']);
39 39 pyroutes.register('files_history_home', '/%(repo_name)s/history/%(revision)s/%(f_path)s', ['repo_name', 'revision', 'f_path']);
40 40 pyroutes.register('files_authors_home', '/%(repo_name)s/authors/%(revision)s/%(f_path)s', ['repo_name', 'revision', 'f_path']);
41 41 pyroutes.register('files_annotate_home', '/%(repo_name)s/annotate/%(revision)s/%(f_path)s', ['repo_name', 'revision', 'f_path']);
42 42 pyroutes.register('files_annotate_previous', '/%(repo_name)s/annotate-previous/%(revision)s/%(f_path)s', ['repo_name', 'revision', 'f_path']);
43 43 pyroutes.register('files_archive_home', '/%(repo_name)s/archive/%(fname)s', ['repo_name', 'fname']);
44 44 pyroutes.register('files_nodelist_home', '/%(repo_name)s/nodelist/%(revision)s/%(f_path)s', ['repo_name', 'revision', 'f_path']);
45 45 pyroutes.register('files_nodetree_full', '/%(repo_name)s/nodetree_full/%(commit_id)s/%(f_path)s', ['repo_name', 'commit_id', 'f_path']);
46 46 pyroutes.register('favicon', '/favicon.ico', []);
47 47 pyroutes.register('robots', '/robots.txt', []);
48 48 pyroutes.register('auth_home', '/_admin/auth*traverse', []);
49 49 pyroutes.register('global_integrations_new', '/_admin/integrations/new', []);
50 50 pyroutes.register('global_integrations_home', '/_admin/integrations', []);
51 51 pyroutes.register('global_integrations_list', '/_admin/integrations/%(integration)s', ['integration']);
52 52 pyroutes.register('global_integrations_create', '/_admin/integrations/%(integration)s/new', ['integration']);
53 53 pyroutes.register('global_integrations_edit', '/_admin/integrations/%(integration)s/%(integration_id)s', ['integration', 'integration_id']);
54 54 pyroutes.register('repo_group_integrations_home', '%(repo_group_name)s/settings/integrations', ['repo_group_name']);
55 55 pyroutes.register('repo_group_integrations_list', '%(repo_group_name)s/settings/integrations/%(integration)s', ['repo_group_name', 'integration']);
56 56 pyroutes.register('repo_group_integrations_new', '%(repo_group_name)s/settings/integrations/new', ['repo_group_name']);
57 57 pyroutes.register('repo_group_integrations_create', '%(repo_group_name)s/settings/integrations/%(integration)s/new', ['repo_group_name', 'integration']);
58 58 pyroutes.register('repo_group_integrations_edit', '%(repo_group_name)s/settings/integrations/%(integration)s/%(integration_id)s', ['repo_group_name', 'integration', 'integration_id']);
59 59 pyroutes.register('repo_integrations_home', '%(repo_name)s/settings/integrations', ['repo_name']);
60 60 pyroutes.register('repo_integrations_list', '%(repo_name)s/settings/integrations/%(integration)s', ['repo_name', 'integration']);
61 61 pyroutes.register('repo_integrations_new', '%(repo_name)s/settings/integrations/new', ['repo_name']);
62 62 pyroutes.register('repo_integrations_create', '%(repo_name)s/settings/integrations/%(integration)s/new', ['repo_name', 'integration']);
63 63 pyroutes.register('repo_integrations_edit', '%(repo_name)s/settings/integrations/%(integration)s/%(integration_id)s', ['repo_name', 'integration', 'integration_id']);
64 64 pyroutes.register('ops_ping', '_admin/ops/ping', []);
65 65 pyroutes.register('admin_home', '/_admin', []);
66 66 pyroutes.register('admin_audit_logs', '_admin/audit_logs', []);
67 67 pyroutes.register('pull_requests_global_0', '_admin/pull_requests/%(pull_request_id)s', ['pull_request_id']);
68 68 pyroutes.register('pull_requests_global_1', '_admin/pull-requests/%(pull_request_id)s', ['pull_request_id']);
69 69 pyroutes.register('pull_requests_global', '_admin/pull-request/%(pull_request_id)s', ['pull_request_id']);
70 70 pyroutes.register('admin_settings_open_source', '_admin/settings/open_source', []);
71 71 pyroutes.register('admin_settings_vcs_svn_generate_cfg', '_admin/settings/vcs/svn_generate_cfg', []);
72 72 pyroutes.register('admin_settings_system', '_admin/settings/system', []);
73 73 pyroutes.register('admin_settings_system_update', '_admin/settings/system/updates', []);
74 74 pyroutes.register('admin_settings_sessions', '_admin/settings/sessions', []);
75 75 pyroutes.register('admin_settings_sessions_cleanup', '_admin/settings/sessions/cleanup', []);
76 76 pyroutes.register('users', '_admin/users', []);
77 77 pyroutes.register('users_data', '_admin/users_data', []);
78 78 pyroutes.register('edit_user_auth_tokens', '_admin/users/%(user_id)s/edit/auth_tokens', ['user_id']);
79 79 pyroutes.register('edit_user_auth_tokens_add', '_admin/users/%(user_id)s/edit/auth_tokens/new', ['user_id']);
80 80 pyroutes.register('edit_user_auth_tokens_delete', '_admin/users/%(user_id)s/edit/auth_tokens/delete', ['user_id']);
81 81 pyroutes.register('edit_user_groups_management', '_admin/users/%(user_id)s/edit/groups_management', ['user_id']);
82 82 pyroutes.register('edit_user_groups_management_updates', '_admin/users/%(user_id)s/edit/edit_user_groups_management/updates', ['user_id']);
83 83 pyroutes.register('edit_user_audit_logs', '_admin/users/%(user_id)s/edit/audit', ['user_id']);
84 84 pyroutes.register('channelstream_connect', '/_admin/channelstream/connect', []);
85 85 pyroutes.register('channelstream_subscribe', '/_admin/channelstream/subscribe', []);
86 86 pyroutes.register('channelstream_proxy', '/_channelstream', []);
87 87 pyroutes.register('login', '/_admin/login', []);
88 88 pyroutes.register('logout', '/_admin/logout', []);
89 89 pyroutes.register('register', '/_admin/register', []);
90 90 pyroutes.register('reset_password', '/_admin/password_reset', []);
91 91 pyroutes.register('reset_password_confirmation', '/_admin/password_reset_confirmation', []);
92 92 pyroutes.register('home', '/', []);
93 93 pyroutes.register('user_autocomplete_data', '/_users', []);
94 94 pyroutes.register('user_group_autocomplete_data', '/_user_groups', []);
95 95 pyroutes.register('repo_list_data', '/_repos', []);
96 96 pyroutes.register('goto_switcher_data', '/_goto_data', []);
97 97 pyroutes.register('repo_summary_explicit', '/%(repo_name)s/summary', ['repo_name']);
98 98 pyroutes.register('repo_summary_commits', '/%(repo_name)s/summary-commits', ['repo_name']);
99 pyroutes.register('repo_commit', '/%(repo_name)s/changeset/%(commit_id)s', ['repo_name', 'commit_id']);
99 100 pyroutes.register('repo_refs_data', '/%(repo_name)s/refs-data', ['repo_name']);
100 101 pyroutes.register('repo_refs_changelog_data', '/%(repo_name)s/refs-data-changelog', ['repo_name']);
101 102 pyroutes.register('repo_stats', '/%(repo_name)s/repo_stats/%(commit_id)s', ['repo_name', 'commit_id']);
102 103 pyroutes.register('tags_home', '/%(repo_name)s/tags', ['repo_name']);
103 104 pyroutes.register('branches_home', '/%(repo_name)s/branches', ['repo_name']);
104 105 pyroutes.register('bookmarks_home', '/%(repo_name)s/bookmarks', ['repo_name']);
105 106 pyroutes.register('pullrequest_show', '/%(repo_name)s/pull-request/%(pull_request_id)s', ['repo_name', 'pull_request_id']);
106 107 pyroutes.register('pullrequest_show_all', '/%(repo_name)s/pull-request', ['repo_name']);
107 108 pyroutes.register('pullrequest_show_all_data', '/%(repo_name)s/pull-request-data', ['repo_name']);
108 109 pyroutes.register('edit_repo', '/%(repo_name)s/settings', ['repo_name']);
109 110 pyroutes.register('edit_repo_advanced', '/%(repo_name)s/settings/advanced', ['repo_name']);
110 111 pyroutes.register('edit_repo_advanced_delete', '/%(repo_name)s/settings/advanced/delete', ['repo_name']);
111 112 pyroutes.register('edit_repo_advanced_locking', '/%(repo_name)s/settings/advanced/locking', ['repo_name']);
112 113 pyroutes.register('edit_repo_advanced_journal', '/%(repo_name)s/settings/advanced/journal', ['repo_name']);
113 114 pyroutes.register('edit_repo_advanced_fork', '/%(repo_name)s/settings/advanced/fork', ['repo_name']);
114 115 pyroutes.register('edit_repo_caches', '/%(repo_name)s/settings/caches', ['repo_name']);
115 116 pyroutes.register('edit_repo_perms', '/%(repo_name)s/settings/permissions', ['repo_name']);
116 117 pyroutes.register('repo_reviewers', '/%(repo_name)s/settings/review/rules', ['repo_name']);
117 118 pyroutes.register('repo_default_reviewers_data', '/%(repo_name)s/settings/review/default-reviewers', ['repo_name']);
118 119 pyroutes.register('repo_maintenance', '/%(repo_name)s/settings/maintenance', ['repo_name']);
119 120 pyroutes.register('repo_maintenance_execute', '/%(repo_name)s/settings/maintenance/execute', ['repo_name']);
120 121 pyroutes.register('strip', '/%(repo_name)s/settings/strip', ['repo_name']);
121 122 pyroutes.register('strip_check', '/%(repo_name)s/settings/strip_check', ['repo_name']);
122 123 pyroutes.register('strip_execute', '/%(repo_name)s/settings/strip_execute', ['repo_name']);
123 124 pyroutes.register('repo_summary', '/%(repo_name)s', ['repo_name']);
124 125 pyroutes.register('repo_summary_slash', '/%(repo_name)s/', ['repo_name']);
125 126 pyroutes.register('repo_group_home', '/%(repo_group_name)s', ['repo_group_name']);
126 127 pyroutes.register('repo_group_home_slash', '/%(repo_group_name)s/', ['repo_group_name']);
127 128 pyroutes.register('search', '/_admin/search', []);
128 129 pyroutes.register('search_repo', '/%(repo_name)s/search', ['repo_name']);
129 130 pyroutes.register('user_profile', '/_profiles/%(username)s', ['username']);
130 131 pyroutes.register('my_account_profile', '/_admin/my_account/profile', []);
131 132 pyroutes.register('my_account_password', '/_admin/my_account/password', []);
132 133 pyroutes.register('my_account_password_update', '/_admin/my_account/password', []);
133 134 pyroutes.register('my_account_auth_tokens', '/_admin/my_account/auth_tokens', []);
134 135 pyroutes.register('my_account_auth_tokens_add', '/_admin/my_account/auth_tokens/new', []);
135 136 pyroutes.register('my_account_auth_tokens_delete', '/_admin/my_account/auth_tokens/delete', []);
136 137 pyroutes.register('my_account_notifications_test_channelstream', '/_admin/my_account/test_channelstream', []);
137 138 pyroutes.register('apiv2', '/_admin/api', []);
138 139 }
@@ -1,615 +1,605 b''
1 1 // # Copyright (C) 2010-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 var prButtonLockChecks = {
21 21 'compare': false,
22 22 'reviewers': false
23 23 };
24 24
25 25 /**
26 26 * lock button until all checks and loads are made. E.g reviewer calculation
27 27 * should prevent from submitting a PR
28 28 * @param lockEnabled
29 29 * @param msg
30 30 * @param scope
31 31 */
32 32 var prButtonLock = function(lockEnabled, msg, scope) {
33 33 scope = scope || 'all';
34 34 if (scope == 'all'){
35 35 prButtonLockChecks['compare'] = !lockEnabled;
36 36 prButtonLockChecks['reviewers'] = !lockEnabled;
37 37 } else if (scope == 'compare') {
38 38 prButtonLockChecks['compare'] = !lockEnabled;
39 39 } else if (scope == 'reviewers'){
40 40 prButtonLockChecks['reviewers'] = !lockEnabled;
41 41 }
42 42 var checksMeet = prButtonLockChecks.compare && prButtonLockChecks.reviewers;
43 43 if (lockEnabled) {
44 44 $('#save').attr('disabled', 'disabled');
45 45 }
46 46 else if (checksMeet) {
47 47 $('#save').removeAttr('disabled');
48 48 }
49 49
50 50 if (msg) {
51 51 $('#pr_open_message').html(msg);
52 52 }
53 53 };
54 54
55 55
56 56 /**
57 57 Generate Title and Description for a PullRequest.
58 58 In case of 1 commits, the title and description is that one commit
59 59 in case of multiple commits, we iterate on them with max N number of commits,
60 60 and build description in a form
61 61 - commitN
62 62 - commitN+1
63 63 ...
64 64
65 65 Title is then constructed from branch names, or other references,
66 66 replacing '-' and '_' into spaces
67 67
68 68 * @param sourceRef
69 69 * @param elements
70 70 * @param limit
71 71 * @returns {*[]}
72 72 */
73 73 var getTitleAndDescription = function(sourceRef, elements, limit) {
74 74 var title = '';
75 75 var desc = '';
76 76
77 77 $.each($(elements).get().reverse().slice(0, limit), function(idx, value) {
78 78 var rawMessage = $(value).find('td.td-description .message').data('messageRaw');
79 79 desc += '- ' + rawMessage.split('\n')[0].replace(/\n+$/, "") + '\n';
80 80 });
81 81 // only 1 commit, use commit message as title
82 82 if (elements.length === 1) {
83 83 title = $(elements[0]).find('td.td-description .message').data('messageRaw').split('\n')[0];
84 84 }
85 85 else {
86 86 // use reference name
87 87 title = sourceRef.replace(/-/g, ' ').replace(/_/g, ' ').capitalizeFirstLetter();
88 88 }
89 89
90 90 return [title, desc]
91 91 };
92 92
93 93
94 94
95 95 ReviewersController = function () {
96 96 var self = this;
97 97 this.$reviewRulesContainer = $('#review_rules');
98 98 this.$rulesList = this.$reviewRulesContainer.find('.pr-reviewer-rules');
99 99 this.forbidReviewUsers = undefined;
100 100 this.$reviewMembers = $('#review_members');
101 101 this.currentRequest = null;
102 102
103 103 this.defaultForbidReviewUsers = function() {
104 104 return [
105 105 {'username': 'default',
106 106 'user_id': templateContext.default_user.user_id}
107 107 ];
108 108 };
109 109
110 110 this.hideReviewRules = function() {
111 111 self.$reviewRulesContainer.hide();
112 112 };
113 113
114 114 this.showReviewRules = function() {
115 115 self.$reviewRulesContainer.show();
116 116 };
117 117
118 118 this.addRule = function(ruleText) {
119 119 self.showReviewRules();
120 120 return '<div>- {0}</div>'.format(ruleText)
121 121 };
122 122
123 123 this.loadReviewRules = function(data) {
124 124 // reset forbidden Users
125 125 this.forbidReviewUsers = self.defaultForbidReviewUsers();
126 126
127 127 // reset state of review rules
128 128 self.$rulesList.html('');
129 129
130 130 if (!data || data.rules === undefined || $.isEmptyObject(data.rules)) {
131 131 // default rule, case for older repo that don't have any rules stored
132 132 self.$rulesList.append(
133 133 self.addRule(
134 134 _gettext('All reviewers must vote.'))
135 135 );
136 136 return self.forbidReviewUsers
137 137 }
138 138
139 139 if (data.rules.voting !== undefined) {
140 140 if (data.rules.voting < 0){
141 141 self.$rulesList.append(
142 142 self.addRule(
143 143 _gettext('All reviewers must vote.'))
144 144 )
145 145 } else if (data.rules.voting === 1) {
146 146 self.$rulesList.append(
147 147 self.addRule(
148 148 _gettext('At least {0} reviewer must vote.').format(data.rules.voting))
149 149 )
150 150
151 151 } else {
152 152 self.$rulesList.append(
153 153 self.addRule(
154 154 _gettext('At least {0} reviewers must vote.').format(data.rules.voting))
155 155 )
156 156 }
157 157 }
158 158 if (data.rules.use_code_authors_for_review) {
159 159 self.$rulesList.append(
160 160 self.addRule(
161 161 _gettext('Reviewers picked from source code changes.'))
162 162 )
163 163 }
164 164 if (data.rules.forbid_adding_reviewers) {
165 165 $('#add_reviewer_input').remove();
166 166 self.$rulesList.append(
167 167 self.addRule(
168 168 _gettext('Adding new reviewers is forbidden.'))
169 169 )
170 170 }
171 171 if (data.rules.forbid_author_to_review) {
172 172 self.forbidReviewUsers.push(data.rules_data.pr_author);
173 173 self.$rulesList.append(
174 174 self.addRule(
175 175 _gettext('Author is not allowed to be a reviewer.'))
176 176 )
177 177 }
178 178 if (data.rules.forbid_commit_author_to_review) {
179 179
180 180 if (data.rules_data.forbidden_users) {
181 181 $.each(data.rules_data.forbidden_users, function(index, member_data) {
182 182 self.forbidReviewUsers.push(member_data)
183 183 });
184 184
185 185 }
186 186
187 187 self.$rulesList.append(
188 188 self.addRule(
189 189 _gettext('Commit Authors are not allowed to be a reviewer.'))
190 190 )
191 191 }
192 192
193 193 return self.forbidReviewUsers
194 194 };
195 195
196 196 this.loadDefaultReviewers = function(sourceRepo, sourceRef, targetRepo, targetRef) {
197 197
198 198 if (self.currentRequest) {
199 199 // make sure we cleanup old running requests before triggering this
200 200 // again
201 201 self.currentRequest.abort();
202 202 }
203 203
204 204 $('.calculate-reviewers').show();
205 205 // reset reviewer members
206 206 self.$reviewMembers.empty();
207 207
208 208 prButtonLock(true, null, 'reviewers');
209 209 $('#user').hide(); // hide user autocomplete before load
210 210
211 211 var url = pyroutes.url('repo_default_reviewers_data',
212 212 {
213 213 'repo_name': templateContext.repo_name,
214 214 'source_repo': sourceRepo,
215 215 'source_ref': sourceRef[2],
216 216 'target_repo': targetRepo,
217 217 'target_ref': targetRef[2]
218 218 });
219 219
220 220 self.currentRequest = $.get(url)
221 221 .done(function(data) {
222 222 self.currentRequest = null;
223 223
224 224 // review rules
225 225 self.loadReviewRules(data);
226 226
227 227 for (var i = 0; i < data.reviewers.length; i++) {
228 228 var reviewer = data.reviewers[i];
229 229 self.addReviewMember(
230 230 reviewer.user_id, reviewer.firstname,
231 231 reviewer.lastname, reviewer.username,
232 232 reviewer.gravatar_link, reviewer.reasons,
233 233 reviewer.mandatory);
234 234 }
235 235 $('.calculate-reviewers').hide();
236 236 prButtonLock(false, null, 'reviewers');
237 237 $('#user').show(); // show user autocomplete after load
238 238 });
239 239 };
240 240
241 241 // check those, refactor
242 242 this.removeReviewMember = function(reviewer_id, mark_delete) {
243 243 var reviewer = $('#reviewer_{0}'.format(reviewer_id));
244 244
245 245 if(typeof(mark_delete) === undefined){
246 246 mark_delete = false;
247 247 }
248 248
249 249 if(mark_delete === true){
250 250 if (reviewer){
251 251 // now delete the input
252 252 $('#reviewer_{0} input'.format(reviewer_id)).remove();
253 253 // mark as to-delete
254 254 var obj = $('#reviewer_{0}_name'.format(reviewer_id));
255 255 obj.addClass('to-delete');
256 256 obj.css({"text-decoration":"line-through", "opacity": 0.5});
257 257 }
258 258 }
259 259 else{
260 260 $('#reviewer_{0}'.format(reviewer_id)).remove();
261 261 }
262 262 };
263 263
264 264 this.addReviewMember = function(id, fname, lname, nname, gravatar_link, reasons, mandatory) {
265 265 var members = self.$reviewMembers.get(0);
266 266 var reasons_html = '';
267 267 var reasons_inputs = '';
268 268 var reasons = reasons || [];
269 269 var mandatory = mandatory || false;
270 270
271 271 if (reasons) {
272 272 for (var i = 0; i < reasons.length; i++) {
273 273 reasons_html += '<div class="reviewer_reason">- {0}</div>'.format(reasons[i]);
274 274 reasons_inputs += '<input type="hidden" name="reason" value="' + escapeHtml(reasons[i]) + '">';
275 275 }
276 276 }
277 277 var tmpl = '' +
278 278 '<li id="reviewer_{2}" class="reviewer_entry">'+
279 279 '<input type="hidden" name="__start__" value="reviewer:mapping">'+
280 280 '<div class="reviewer_status">'+
281 281 '<div class="flag_status not_reviewed pull-left reviewer_member_status"></div>'+
282 282 '</div>'+
283 283 '<img alt="gravatar" class="gravatar" src="{0}"/>'+
284 284 '<span class="reviewer_name user">{1}</span>'+
285 285 reasons_html +
286 286 '<input type="hidden" name="user_id" value="{2}">'+
287 287 '<input type="hidden" name="__start__" value="reasons:sequence">'+
288 288 '{3}'+
289 289 '<input type="hidden" name="__end__" value="reasons:sequence">';
290 290
291 291 if (mandatory) {
292 292 tmpl += ''+
293 293 '<div class="reviewer_member_mandatory_remove">' +
294 294 '<i class="icon-remove-sign"></i>'+
295 295 '</div>' +
296 296 '<input type="hidden" name="mandatory" value="true">'+
297 297 '<div class="reviewer_member_mandatory">' +
298 298 '<i class="icon-lock" title="Mandatory reviewer"></i>'+
299 299 '</div>';
300 300
301 301 } else {
302 302 tmpl += ''+
303 303 '<input type="hidden" name="mandatory" value="false">'+
304 304 '<div class="reviewer_member_remove action_button" onclick="reviewersController.removeReviewMember({2})">' +
305 305 '<i class="icon-remove-sign"></i>'+
306 306 '</div>';
307 307 }
308 308 // continue template
309 309 tmpl += ''+
310 310 '<input type="hidden" name="__end__" value="reviewer:mapping">'+
311 311 '</li>' ;
312 312
313 313 var displayname = "{0} ({1} {2})".format(
314 314 nname, escapeHtml(fname), escapeHtml(lname));
315 315 var element = tmpl.format(gravatar_link,displayname,id,reasons_inputs);
316 316 // check if we don't have this ID already in
317 317 var ids = [];
318 318 var _els = self.$reviewMembers.find('li').toArray();
319 319 for (el in _els){
320 320 ids.push(_els[el].id)
321 321 }
322 322
323 323 var userAllowedReview = function(userId) {
324 324 var allowed = true;
325 325 $.each(self.forbidReviewUsers, function(index, member_data) {
326 326 if (parseInt(userId) === member_data['user_id']) {
327 327 allowed = false;
328 328 return false // breaks the loop
329 329 }
330 330 });
331 331 return allowed
332 332 };
333 333
334 334 var userAllowed = userAllowedReview(id);
335 335 if (!userAllowed){
336 336 alert(_gettext('User `{0}` not allowed to be a reviewer').format(nname));
337 337 }
338 338 var shouldAdd = userAllowed && ids.indexOf('reviewer_'+id) == -1;
339 339
340 340 if(shouldAdd) {
341 341 // only add if it's not there
342 342 members.innerHTML += element;
343 343 }
344 344
345 345 };
346 346
347 347 this.updateReviewers = function(repo_name, pull_request_id){
348 348 var postData = '_method=put&' + $('#reviewers input').serialize();
349 349 _updatePullRequest(repo_name, pull_request_id, postData);
350 350 };
351 351
352 352 };
353 353
354 354
355 355 var _updatePullRequest = function(repo_name, pull_request_id, postData) {
356 356 var url = pyroutes.url(
357 357 'pullrequest_update',
358 358 {"repo_name": repo_name, "pull_request_id": pull_request_id});
359 359 if (typeof postData === 'string' ) {
360 360 postData += '&csrf_token=' + CSRF_TOKEN;
361 361 } else {
362 362 postData.csrf_token = CSRF_TOKEN;
363 363 }
364 364 var success = function(o) {
365 365 window.location.reload();
366 366 };
367 367 ajaxPOST(url, postData, success);
368 368 };
369 369
370 370 /**
371 * PULL REQUEST reject & close
372 */
373 var closePullRequest = function(repo_name, pull_request_id) {
374 var postData = {
375 '_method': 'put',
376 'close_pull_request': true};
377 _updatePullRequest(repo_name, pull_request_id, postData);
378 };
379
380 /**
381 371 * PULL REQUEST update commits
382 372 */
383 373 var updateCommits = function(repo_name, pull_request_id) {
384 374 var postData = {
385 375 '_method': 'put',
386 376 'update_commits': true};
387 377 _updatePullRequest(repo_name, pull_request_id, postData);
388 378 };
389 379
390 380
391 381 /**
392 382 * PULL REQUEST edit info
393 383 */
394 384 var editPullRequest = function(repo_name, pull_request_id, title, description) {
395 385 var url = pyroutes.url(
396 386 'pullrequest_update',
397 387 {"repo_name": repo_name, "pull_request_id": pull_request_id});
398 388
399 389 var postData = {
400 390 '_method': 'put',
401 391 'title': title,
402 392 'description': description,
403 393 'edit_pull_request': true,
404 394 'csrf_token': CSRF_TOKEN
405 395 };
406 396 var success = function(o) {
407 397 window.location.reload();
408 398 };
409 399 ajaxPOST(url, postData, success);
410 400 };
411 401
412 402 var initPullRequestsCodeMirror = function (textAreaId) {
413 403 var ta = $(textAreaId).get(0);
414 404 var initialHeight = '100px';
415 405
416 406 // default options
417 407 var codeMirrorOptions = {
418 408 mode: "text",
419 409 lineNumbers: false,
420 410 indentUnit: 4,
421 411 theme: 'rc-input'
422 412 };
423 413
424 414 var codeMirrorInstance = CodeMirror.fromTextArea(ta, codeMirrorOptions);
425 415 // marker for manually set description
426 416 codeMirrorInstance._userDefinedDesc = false;
427 417 codeMirrorInstance.setSize(null, initialHeight);
428 418 codeMirrorInstance.on("change", function(instance, changeObj) {
429 419 var height = initialHeight;
430 420 var lines = instance.lineCount();
431 421 if (lines > 6 && lines < 20) {
432 422 height = "auto"
433 423 }
434 424 else if (lines >= 20) {
435 425 height = 20 * 15;
436 426 }
437 427 instance.setSize(null, height);
438 428
439 429 // detect if the change was trigger by auto desc, or user input
440 430 changeOrigin = changeObj.origin;
441 431
442 432 if (changeOrigin === "setValue") {
443 433 cmLog.debug('Change triggered by setValue');
444 434 }
445 435 else {
446 436 cmLog.debug('user triggered change !');
447 437 // set special marker to indicate user has created an input.
448 438 instance._userDefinedDesc = true;
449 439 }
450 440
451 441 });
452 442
453 443 return codeMirrorInstance
454 444 };
455 445
456 446 /**
457 447 * Reviewer autocomplete
458 448 */
459 449 var ReviewerAutoComplete = function(inputId) {
460 450 $(inputId).autocomplete({
461 451 serviceUrl: pyroutes.url('user_autocomplete_data'),
462 452 minChars:2,
463 453 maxHeight:400,
464 454 deferRequestBy: 300, //miliseconds
465 455 showNoSuggestionNotice: true,
466 456 tabDisabled: true,
467 457 autoSelectFirst: true,
468 458 params: { user_id: templateContext.rhodecode_user.user_id, user_groups:true, user_groups_expand:true, skip_default_user:true },
469 459 formatResult: autocompleteFormatResult,
470 460 lookupFilter: autocompleteFilterResult,
471 461 onSelect: function(element, data) {
472 462
473 463 var reasons = [_gettext('added manually by "{0}"').format(templateContext.rhodecode_user.username)];
474 464 if (data.value_type == 'user_group') {
475 465 reasons.push(_gettext('member of "{0}"').format(data.value_display));
476 466
477 467 $.each(data.members, function(index, member_data) {
478 468 reviewersController.addReviewMember(
479 469 member_data.id, member_data.first_name, member_data.last_name,
480 470 member_data.username, member_data.icon_link, reasons);
481 471 })
482 472
483 473 } else {
484 474 reviewersController.addReviewMember(
485 475 data.id, data.first_name, data.last_name,
486 476 data.username, data.icon_link, reasons);
487 477 }
488 478
489 479 $(inputId).val('');
490 480 }
491 481 });
492 482 };
493 483
494 484
495 485 VersionController = function () {
496 486 var self = this;
497 487 this.$verSource = $('input[name=ver_source]');
498 488 this.$verTarget = $('input[name=ver_target]');
499 489 this.$showVersionDiff = $('#show-version-diff');
500 490
501 491 this.adjustRadioSelectors = function (curNode) {
502 492 var getVal = function (item) {
503 493 if (item == 'latest') {
504 494 return Number.MAX_SAFE_INTEGER
505 495 }
506 496 else {
507 497 return parseInt(item)
508 498 }
509 499 };
510 500
511 501 var curVal = getVal($(curNode).val());
512 502 var cleared = false;
513 503
514 504 $.each(self.$verSource, function (index, value) {
515 505 var elVal = getVal($(value).val());
516 506
517 507 if (elVal > curVal) {
518 508 if ($(value).is(':checked')) {
519 509 cleared = true;
520 510 }
521 511 $(value).attr('disabled', 'disabled');
522 512 $(value).removeAttr('checked');
523 513 $(value).css({'opacity': 0.1});
524 514 }
525 515 else {
526 516 $(value).css({'opacity': 1});
527 517 $(value).removeAttr('disabled');
528 518 }
529 519 });
530 520
531 521 if (cleared) {
532 522 // if we unchecked an active, set the next one to same loc.
533 523 $(this.$verSource).filter('[value={0}]'.format(
534 524 curVal)).attr('checked', 'checked');
535 525 }
536 526
537 527 self.setLockAction(false,
538 528 $(curNode).data('verPos'),
539 529 $(this.$verSource).filter(':checked').data('verPos')
540 530 );
541 531 };
542 532
543 533
544 534 this.attachVersionListener = function () {
545 535 self.$verTarget.change(function (e) {
546 536 self.adjustRadioSelectors(this)
547 537 });
548 538 self.$verSource.change(function (e) {
549 539 self.adjustRadioSelectors(self.$verTarget.filter(':checked'))
550 540 });
551 541 };
552 542
553 543 this.init = function () {
554 544
555 545 var curNode = self.$verTarget.filter(':checked');
556 546 self.adjustRadioSelectors(curNode);
557 547 self.setLockAction(true);
558 548 self.attachVersionListener();
559 549
560 550 };
561 551
562 552 this.setLockAction = function (state, selectedVersion, otherVersion) {
563 553 var $showVersionDiff = this.$showVersionDiff;
564 554
565 555 if (state) {
566 556 $showVersionDiff.attr('disabled', 'disabled');
567 557 $showVersionDiff.addClass('disabled');
568 558 $showVersionDiff.html($showVersionDiff.data('labelTextLocked'));
569 559 }
570 560 else {
571 561 $showVersionDiff.removeAttr('disabled');
572 562 $showVersionDiff.removeClass('disabled');
573 563
574 564 if (selectedVersion == otherVersion) {
575 565 $showVersionDiff.html($showVersionDiff.data('labelTextShow'));
576 566 } else {
577 567 $showVersionDiff.html($showVersionDiff.data('labelTextDiff'));
578 568 }
579 569 }
580 570
581 571 };
582 572
583 573 this.showVersionDiff = function () {
584 574 var target = self.$verTarget.filter(':checked');
585 575 var source = self.$verSource.filter(':checked');
586 576
587 577 if (target.val() && source.val()) {
588 578 var params = {
589 579 'pull_request_id': templateContext.pull_request_data.pull_request_id,
590 580 'repo_name': templateContext.repo_name,
591 581 'version': target.val(),
592 582 'from_version': source.val()
593 583 };
594 584 window.location = pyroutes.url('pullrequest_show', params)
595 585 }
596 586
597 587 return false;
598 588 };
599 589
600 590 this.toggleVersionView = function (elem) {
601 591
602 592 if (this.$showVersionDiff.is(':visible')) {
603 593 $('.version-pr').hide();
604 594 this.$showVersionDiff.hide();
605 595 $(elem).html($(elem).data('toggleOn'))
606 596 } else {
607 597 $('.version-pr').show();
608 598 this.$showVersionDiff.show();
609 599 $(elem).html($(elem).data('toggleOff'))
610 600 }
611 601
612 602 return false
613 603 }
614 604
615 605 }; No newline at end of file
@@ -1,4 +1,4 b''
1 1 ## this is a dummy html file for partial rendering on server and sending
2 2 ## generated output via ajax after comment submit
3 3 <%namespace name="comment" file="/changeset/changeset_file_comment.mako"/>
4 ${comment.comment_block(c.co, inline=c.inline_comment)}
4 ${comment.comment_block(c.co, inline=c.co.is_inline)}
@@ -1,964 +1,961 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="/debug_style/index.html"/>
3 3
4 4 <%def name="breadcrumbs_links()">
5 5 ${h.link_to(_('Style'), h.url('debug_style_home'))}
6 6 &raquo;
7 7 ${c.active}
8 8 </%def>
9 9
10 10
11 11 <%def name="real_main()">
12 12 <div class="box">
13 13 <div class="title">
14 14 ${self.breadcrumbs()}
15 15 </div>
16 16
17 17 <div class='sidebar-col-wrapper'>
18 18 ${self.sidebar()}
19 19
20 20 <div class="main-content">
21 21
22 22 <h2>Collapsable Content</h2>
23 23 <p>Where a section may have a very long list of information, it can be desirable to use collapsable content. There is a premade function for showing/hiding elements, though its use may or may not be practical, depending on the situation. Use it, or don't, on a case-by-case basis.</p>
24 24
25 25 <p><strong>To use the collapsable-content function:</strong> Create a toggle button using <code>&lt;div class="btn-collapse"&gt;Show More&lt;/div&gt;</code> and a data attribute using <code>data-toggle</code>. Clicking this button will toggle any sibling element(s) containing the class <code>collapsable-content</code> and an identical <code>data-toggle</code> attribute. It will also change the button to read "Show Less"; another click toggles it back to the previous state. Ideally, use pre-existing elements and add the class and attribute; creating a new div around the existing content may lead to unexpected results, as the toggle function will use <code>display:block</code> if no previous display specification was found.
26 26 </p>
27 27 <p>Notes:</p>
28 28 <ul>
29 29 <li>Changes made to the text of the button will require adjustment to the function, but for the sake of consistency and user experience, this is best avoided. </li>
30 30 <li>Collapsable content inside of a pjax loaded container will require <code>collapsableContent();</code> to be called from within the container. No variables are necessary.</li>
31 31 </ul>
32 32
33 33 </div> <!-- .main-content -->
34 34 </div> <!-- .sidebar-col-wrapper -->
35 35 </div> <!-- .box -->
36 36
37 37 <!-- CONTENT -->
38 38 <div id="content" class="wrapper">
39 39
40 40 <div class="main">
41 41
42 42 <div class="box">
43 43 <div class="title">
44 44 <h1>
45 45 Diff: enable filename with spaces on diffs
46 46 </h1>
47 47 <h1>
48 48 <i class="icon-hg" ></i>
49 49
50 50 <i class="icon-lock"></i>
51 51 <span><a href="/rhodecode-momentum">rhodecode-momentum</a></span>
52 52
53 53 </h1>
54 54 </div>
55 55
56 56 <div class="box pr-summary">
57 57 <div class="summary-details block-left">
58 58
59 59 <div class="pr-details-title">
60 60
61 61 Pull request #720 From Tue, 17 Feb 2015 16:21:38
62 62 <div class="btn-collapse" data-toggle="description">Show More</div>
63 63 </div>
64 64 <div id="summary" class="fields pr-details-content">
65 65 <div class="field">
66 66 <div class="label-summary">
67 67 <label>Origin:</label>
68 68 </div>
69 69 <div class="input">
70 70 <div>
71 71 <span class="tag">
72 72 <a href="/andersonsantos/rhodecode-momentum-fork#fix_574">book: fix_574</a>
73 73 </span>
74 74 <span class="clone-url">
75 75 <a href="/andersonsantos/rhodecode-momentum-fork">https://code.rhodecode.com/andersonsantos/rhodecode-momentum-fork</a>
76 76 </span>
77 77 </div>
78 78 <div>
79 79 <br>
80 80 <input type="text" value="hg pull -r 46b3d50315f0 https://code.rhodecode.com/andersonsantos/rhodecode-momentum-fork" readonly="readonly">
81 81 </div>
82 82 </div>
83 83 </div>
84 84 <div class="field">
85 85 <div class="label-summary">
86 86 <label>Review:</label>
87 87 </div>
88 88 <div class="input">
89 89 <div class="flag_status under_review tooltip pull-left" title="Pull request status calculated from votes"></div>
90 90 <span class="changeset-status-lbl tooltip" title="Pull request status calculated from votes">
91 91 Under Review
92 92 </span>
93 93
94 94 </div>
95 95 </div>
96 96 <div class="field collapsable-content" data-toggle="description">
97 97 <div class="label-summary">
98 98 <label>Description:</label>
99 99 </div>
100 100 <div class="input">
101 101 <div class="pr-description">Fixing issue <a class="issue- tracker-link" href="http://bugs.rhodecode.com/issues/574"># 574</a>, changing regex for capturing filenames</div>
102 102 </div>
103 103 </div>
104 104 <div class="field collapsable-content" data-toggle="description">
105 105 <div class="label-summary">
106 106 <label>Comments:</label>
107 107 </div>
108 108 <div class="input">
109 109 <div>
110 110 <div class="comments-number">
111 111 <a href="#inline-comments-container">0 Pull request comments</a>,
112 112 0 Inline Comments
113 113 </div>
114 114 </div>
115 115 </div>
116 116 </div>
117 117 </div>
118 118 </div>
119 119 <div>
120 120 <div class="reviewers-title block-right">
121 121 <div class="pr-details-title">
122 122 Author
123 123 </div>
124 124 </div>
125 125 <div class="block-right pr-details-content reviewers">
126 126 <ul class="group_members">
127 127 <li>
128 128 <img class="gravatar" src="https://secure.gravatar.com/avatar/72706ebd30734451af9ff3fb59f05ff1?d=identicon&amp;s=32" height="16" width="16">
129 129 <span class="user"> <a href="/_profiles/lolek">lolek (Lolek Santos)</a></span>
130 130 </li>
131 131 </ul>
132 132 </div>
133 133 <div class="reviewers-title block-right">
134 134 <div class="pr-details-title">
135 135 Pull request reviewers
136 136 <span class="btn-collapse" data-toggle="reviewers">Show More</span>
137 137 </div>
138 138
139 139 </div>
140 140 <div id="reviewers" class="block-right pr-details-content reviewers">
141 141
142 142 <ul id="review_members" class="group_members">
143 143 <li id="reviewer_70">
144 144 <div class="reviewers_member">
145 145 <div class="reviewer_status tooltip pull-left" title="Not Reviewed">
146 146 <div class="flag_status rejected pull-left reviewer_member_status"></div>
147 147 </div>
148 148 <img class="gravatar" src="https://secure.gravatar.com/avatar/153a0fab13160b3e64a2cbc7c0373506?d=identicon&amp;s=32" height="16" width="16">
149 149 <span class="user"> <a href="/_profiles/jenkins-tests">jenkins-tests</a> (reviewer)</span>
150 150 </div>
151 151 <input id="reviewer_70_input" type="hidden" value="70" name="review_members">
152 152 <div class="reviewer_member_remove action_button" onclick="removeReviewMember(70, true)" style="visibility: hidden;">
153 153 <i class="icon-remove-sign"></i>
154 154 </div>
155 155 </li>
156 156 <li id="reviewer_33" class="collapsable-content" data-toggle="reviewers">
157 157 <div class="reviewers_member">
158 158 <div class="reviewer_status tooltip pull-left" title="Not Reviewed">
159 159 <div class="flag_status approved pull-left reviewer_member_status"></div>
160 160 </div>
161 161 <img class="gravatar" src="https://secure.gravatar.com/avatar/ffd6a317ec2b66be880143cd8459d0d9?d=identicon&amp;s=32" height="16" width="16">
162 162 <span class="user"> <a href="/_profiles/jenkins-tests">garbas (Rok Garbas)</a> (reviewer)</span>
163 163 </div>
164 164 </li>
165 165 <li id="reviewer_2" class="collapsable-content" data-toggle="reviewers">
166 166 <div class="reviewers_member">
167 167 <div class="reviewer_status tooltip pull-left" title="Not Reviewed">
168 168 <div class="flag_status not_reviewed pull-left reviewer_member_status"></div>
169 169 </div>
170 170 <img class="gravatar" src="https://secure.gravatar.com/avatar/aad9d40cac1259ea39b5578554ad9d64?d=identicon&amp;s=32" height="16" width="16">
171 171 <span class="user"> <a href="/_profiles/jenkins-tests">marcink (Marcin Kuzminski)</a> (reviewer)</span>
172 172 </div>
173 173 </li>
174 174 <li id="reviewer_36" class="collapsable-content" data-toggle="reviewers">
175 175 <div class="reviewers_member">
176 176 <div class="reviewer_status tooltip pull-left" title="Not Reviewed">
177 177 <div class="flag_status approved pull-left reviewer_member_status"></div>
178 178 </div>
179 179 <img class="gravatar" src="https://secure.gravatar.com/avatar/7a4da001a0af0016ed056ab523255db9?d=identicon&amp;s=32" height="16" width="16">
180 180 <span class="user"> <a href="/_profiles/jenkins-tests">johbo (Johannes Bornhold)</a> (reviewer)</span>
181 181 </div>
182 182 </li>
183 183 <li id="reviewer_47" class="collapsable-content" data-toggle="reviewers">
184 184 <div class="reviewers_member">
185 185 <div class="reviewer_status tooltip pull-left" title="Not Reviewed">
186 186 <div class="flag_status under_review pull-left reviewer_member_status"></div>
187 187 </div>
188 188 <img class="gravatar" src="https://secure.gravatar.com/avatar/8f6dc00dce79d6bd7d415be5cea6a008?d=identicon&amp;s=32" height="16" width="16">
189 189 <span class="user"> <a href="/_profiles/jenkins-tests">lisaq (Lisa Quatmann)</a> (reviewer)</span>
190 190 </div>
191 191 </li>
192 192 <li id="reviewer_49" class="collapsable-content" data-toggle="reviewers">
193 193 <div class="reviewers_member">
194 194 <div class="reviewer_status tooltip pull-left" title="Not Reviewed">
195 195 <div class="flag_status approved pull-left reviewer_member_status"></div>
196 196 </div>
197 197 <img class="gravatar" src="https://secure.gravatar.com/avatar/89f722927932a8f737a0feafb03a606e?d=identicon&amp;s=32" height="16" width="16">
198 198 <span class="user"> <a href="/_profiles/jenkins-tests">paris (Paris Kolios)</a> (reviewer)</span>
199 199 </div>
200 200 </li>
201 201 <li id="reviewer_50" class="collapsable-content" data-toggle="reviewers">
202 202 <div class="reviewers_member">
203 203 <div class="reviewer_status tooltip pull-left" title="Not Reviewed">
204 204 <div class="flag_status approved pull-left reviewer_member_status"></div>
205 205 </div>
206 206 <img class="gravatar" src="https://secure.gravatar.com/avatar/081322c975e8545ec269372405fbd016?d=identicon&amp;s=32" height="16" width="16">
207 207 <span class="user"> <a href="/_profiles/jenkins-tests">ergo (Marcin Lulek)</a> (reviewer)</span>
208 208 </div>
209 209 </li>
210 210 <li id="reviewer_54" class="collapsable-content" data-toggle="reviewers">
211 211 <div class="reviewers_member">
212 212 <div class="reviewer_status tooltip pull-left" title="Not Reviewed">
213 213 <div class="flag_status under_review pull-left reviewer_member_status"></div>
214 214 </div>
215 215 <img class="gravatar" src="https://secure.gravatar.com/avatar/72706ebd30734451af9ff3fb59f05ff1?d=identicon&amp;s=32" height="16" width="16">
216 216 <span class="user"> <a href="/_profiles/jenkins-tests">anderson (Anderson Santos)</a> (reviewer)</span>
217 217 </div>
218 218 </li>
219 219 <li id="reviewer_57" class="collapsable-content" data-toggle="reviewers">
220 220 <div class="reviewers_member">
221 221 <div class="reviewer_status tooltip pull-left" title="Not Reviewed">
222 222 <div class="flag_status approved pull-left reviewer_member_status"></div>
223 223 </div>
224 224 <img class="gravatar" src="https://secure.gravatar.com/avatar/23e2ee8f5fd462cba8129a40cc1e896c?d=identicon&amp;s=32" height="16" width="16">
225 225 <span class="user"> <a href="/_profiles/jenkins-tests">gmgauthier (Greg Gauthier)</a> (reviewer)</span>
226 226 </div>
227 227 </li>
228 228 <li id="reviewer_31" class="collapsable-content" data-toggle="reviewers">
229 229 <div class="reviewers_member">
230 230 <div class="reviewer_status tooltip pull-left" title="Not Reviewed">
231 231 <div class="flag_status under_review pull-left reviewer_member_status"></div>
232 232 </div>
233 233 <img class="gravatar" src="https://secure.gravatar.com/avatar/0c9a7e6674b6f0b35d98dbe073e3f0ab?d=identicon&amp;s=32" height="16" width="16">
234 234 <span class="user"> <a href="/_profiles/jenkins-tests">ostrobel (Oliver Strobel)</a> (reviewer)</span>
235 235 </div>
236 236 </li>
237 237 </ul>
238 238 <div id="add_reviewer_input" class="ac" style="display: none;">
239 239 </div>
240 240 </div>
241 241 </div>
242 242 </div>
243 243 </div>
244 244 <div class="box">
245 245 <div class="table" >
246 246 <div id="changeset_compare_view_content">
247 247 <div class="compare_view_commits_title">
248 248 <h2>Compare View: 6 commits<span class="btn-collapse" data-toggle="commits">Show More</span></h2>
249 249
250 250 </div>
251 251 <div class="container">
252 252
253 253
254 254 <table class="rctable compare_view_commits">
255 255 <tr>
256 256 <th>Time</th>
257 257 <th>Author</th>
258 258 <th>Commit</th>
259 259 <th></th>
260 260 <th>Title</th>
261 261 </tr>
262 262 <tr id="row-7e83e5cd7812dd9e055ce30e77c65cdc08154b43" commit_id="7e83e5cd7812dd9e055ce30e77c65cdc08154b43" class="compare_select">
263 263 <td class="td-time">
264 264 <span class="tooltip" title="3 hours and 23 minutes ago" tt_title="3 hours and 23 minutes ago">2015-02-18 10:13:34</span>
265 265 </td>
266 266 <td class="td-user">
267 267 <div class="gravatar_with_user">
268 268 <img class="gravatar" alt="gravatar" src="https://secure.gravatar.com/avatar/02cc31cea73b88b7209ba302c5967a9d?d=identicon&amp;s=16">
269 269 <span title="Lolek Santos <lolek@rhodecode.com>" class="user">brian (Brian Butler)</span>
270 270 </div>
271 271 </td>
272 272 <td class="td-hash">
273 273 <code>
274 274 <a href="/brian/documentation-rep/changeset/7e83e5cd7812dd9e055ce30e77c65cdc08154b43">r395:7e83e5cd7812</a>
275 275 </code>
276 276 </td>
277 277 <td class="expand_commit" data-commit-id="7e83e5cd7812dd9e055ce30e77c65cdc08154b43" title="Expand commit message">
278 278 <div class="show_more_col">
279 279 <i class="show_more"></i>
280 280 </div>
281 281 </td>
282 282 <td class="mid td-description">
283 283 <div class="log-container truncate-wrap">
284 284 <div id="c-7e83e5cd7812dd9e055ce30e77c65cdc08154b43" class="message truncate">rep: added how we doc to guide</div>
285 285 </div>
286 286 </td>
287 287 </tr>
288 288 <tr id="row-48ce1581bdb3aa7679c246cbdd3fb030623f5c87" commit_id="48ce1581bdb3aa7679c246cbdd3fb030623f5c87" class="compare_select">
289 289 <td class="td-time">
290 290 <span class="tooltip" title="4 hours and 18 minutes ago">2015-02-18 09:18:31</span>
291 291 </td>
292 292 <td class="td-user">
293 293 <div class="gravatar_with_user">
294 294 <img class="gravatar" alt="gravatar" src="https://secure.gravatar.com/avatar/02cc31cea73b88b7209ba302c5967a9d?d=identicon&amp;s=16">
295 295 <span title="Lolek Santos <lolek@rhodecode.com>" class="user">brian (Brian Butler)</span>
296 296 </div>
297 297 </td>
298 298 <td class="td-hash">
299 299 <code>
300 300 <a href="/brian/documentation-rep/changeset/48ce1581bdb3aa7679c246cbdd3fb030623f5c87">r394:48ce1581bdb3</a>
301 301 </code>
302 302 </td>
303 303 <td class="expand_commit" data-commit-id="48ce1581bdb3aa7679c246cbdd3fb030623f5c87" title="Expand commit message">
304 304 <div class="show_more_col">
305 305 <i class="show_more"></i>
306 306 </div>
307 307 </td>
308 308 <td class="mid td-description">
309 309 <div class="log-container truncate-wrap">
310 310 <div id="c-48ce1581bdb3aa7679c246cbdd3fb030623f5c87" class="message truncate">repo 0004 - typo</div>
311 311 </div>
312 312 </td>
313 313 </tr>
314 314 <tr id="row-982d857aafb4c71e7686e419c32b71c9a837257d" commit_id="982d857aafb4c71e7686e419c32b71c9a837257d" class="compare_select collapsable-content" data-toggle="commits">
315 315 <td class="td-time">
316 316 <span class="tooltip" title="4 hours and 22 minutes ago">2015-02-18 09:14:45</span>
317 317 </td>
318 318 <td class="td-user">
319 319 <span class="gravatar" commit_id="982d857aafb4c71e7686e419c32b71c9a837257d">
320 320 <img alt="gravatar" src="https://secure.gravatar.com/avatar/02cc31cea73b88b7209ba302c5967a9d?d=identicon&amp;s=28" height="14" width="14">
321 321 </span>
322 322 <span class="author">brian (Brian Butler)</span>
323 323 </td>
324 324 <td class="td-hash">
325 325 <code>
326 326 <a href="/brian/documentation-rep/changeset/982d857aafb4c71e7686e419c32b71c9a837257d">r393:982d857aafb4</a>
327 327 </code>
328 328 </td>
329 329 <td class="expand_commit" data-commit-id="982d857aafb4c71e7686e419c32b71c9a837257d" title="Expand commit message">
330 330 <div class="show_more_col">
331 331 <i class="show_more"></i>
332 332 </div>
333 333 </td>
334 334 <td class="mid td-description">
335 335 <div class="log-container truncate-wrap">
336 336 <div id="c-982d857aafb4c71e7686e419c32b71c9a837257d" class="message truncate">internals: how to doc section added</div>
337 337 </div>
338 338 </td>
339 339 </tr>
340 340 <tr id="row-4c7258ad1af6dae91bbaf87a933e3597e676fab8" commit_id="4c7258ad1af6dae91bbaf87a933e3597e676fab8" class="compare_select collapsable-content" data-toggle="commits">
341 341 <td class="td-time">
342 342 <span class="tooltip" title="20 hours and 16 minutes ago">2015-02-17 17:20:44</span>
343 343 </td>
344 344 <td class="td-user">
345 345 <span class="gravatar" commit_id="4c7258ad1af6dae91bbaf87a933e3597e676fab8">
346 346 <img alt="gravatar" src="https://secure.gravatar.com/avatar/02cc31cea73b88b7209ba302c5967a9d?d=identicon&amp;s=28" height="14" width="14">
347 347 </span>
348 348 <span class="author">brian (Brian Butler)</span>
349 349 </td>
350 350 <td class="td-hash">
351 351 <code>
352 352 <a href="/brian/documentation-rep/changeset/4c7258ad1af6dae91bbaf87a933e3597e676fab8">r392:4c7258ad1af6</a>
353 353 </code>
354 354 </td>
355 355 <td class="expand_commit" data-commit-id="4c7258ad1af6dae91bbaf87a933e3597e676fab8" title="Expand commit message">
356 356 <div class="show_more_col">
357 357 <i class="show_more"></i>
358 358 </div>
359 359 </td>
360 360 <td class="mid td-description">
361 361 <div class="log-container truncate-wrap">
362 362 <div id="c-4c7258ad1af6dae91bbaf87a933e3597e676fab8" class="message truncate">REP: 0004 Documentation standards</div>
363 363 </div>
364 364 </td>
365 365 </tr>
366 366 <tr id="row-46b3d50315f0f2b1f64485ac95af4f384948f9cb" commit_id="46b3d50315f0f2b1f64485ac95af4f384948f9cb" class="compare_select collapsable-content" data-toggle="commits">
367 367 <td class="td-time">
368 368 <span class="tooltip" title="18 hours and 19 minutes ago">2015-02-17 16:18:49</span>
369 369 </td>
370 370 <td class="td-user">
371 371 <span class="gravatar" commit_id="46b3d50315f0f2b1f64485ac95af4f384948f9cb">
372 372 <img alt="gravatar" src="https://secure.gravatar.com/avatar/72706ebd30734451af9ff3fb59f05ff1?d=identicon&amp;s=28" height="14" width="14">
373 373 </span>
374 374 <span class="author">anderson (Anderson Santos)</span>
375 375 </td>
376 376 <td class="td-hash">
377 377 <code>
378 378 <a href="/andersonsantos/rhodecode-momentum-fork/changeset/46b3d50315f0f2b1f64485ac95af4f384948f9cb">r8743:46b3d50315f0</a>
379 379 </code>
380 380 </td>
381 381 <td class="expand_commit" data-commit-id="46b3d50315f0f2b1f64485ac95af4f384948f9cb" title="Expand commit message">
382 382 <div class="show_more_col">
383 383 <i class="show_more" ></i>
384 384 </div>
385 385 </td>
386 386 <td class="mid td-description">
387 387 <div class="log-container truncate-wrap">
388 388 <div id="c-46b3d50315f0f2b1f64485ac95af4f384948f9cb" class="message truncate">Diff: created tests for the diff with filenames with spaces</div>
389 389
390 390 </div>
391 391 </td>
392 392 </tr>
393 393 <tr id="row-1e57d2549bd6c34798075bf05ac39f708bb33b90" commit_id="1e57d2549bd6c34798075bf05ac39f708bb33b90" class="compare_select collapsable-content" data-toggle="commits">
394 394 <td class="td-time">
395 395 <span class="tooltip" title="2 days ago">2015-02-16 10:06:08</span>
396 396 </td>
397 397 <td class="td-user">
398 398 <span class="gravatar" commit_id="1e57d2549bd6c34798075bf05ac39f708bb33b90">
399 399 <img alt="gravatar" src="https://secure.gravatar.com/avatar/72706ebd30734451af9ff3fb59f05ff1?d=identicon&amp;s=28" height="14" width="14">
400 400 </span>
401 401 <span class="author">anderson (Anderson Santos)</span>
402 402 </td>
403 403 <td class="td-hash">
404 404 <code>
405 405 <a href="/andersonsantos/rhodecode-momentum-fork/changeset/1e57d2549bd6c34798075bf05ac39f708bb33b90">r8742:1e57d2549bd6</a>
406 406 </code>
407 407 </td>
408 408 <td class="expand_commit" data-commit-id="1e57d2549bd6c34798075bf05ac39f708bb33b90" title="Expand commit message">
409 409 <div class="show_more_col">
410 410 <i class="show_more" ></i>
411 411 </div>
412 412 </td>
413 413 <td class="mid td-description">
414 414 <div class="log-container truncate-wrap">
415 415 <div id="c-1e57d2549bd6c34798075bf05ac39f708bb33b90" class="message truncate">Diff: fix renaming files with spaces <a class="issue-tracker-link" href="http://bugs.rhodecode.com/issues/574">#574</a></div>
416 416
417 417 </div>
418 418 </td>
419 419 </tr>
420 420 </table>
421 421 </div>
422 422
423 423 <script>
424 424 $('.expand_commit').on('click',function(e){
425 425 $(this).children('i').hide();
426 426 var cid = $(this).data('commitId');
427 427 $('#c-'+cid).css({'height': 'auto', 'margin': '.65em 1em .65em 0','white-space': 'pre-line', 'text-overflow': 'initial', 'overflow':'visible'})
428 428 $('#t-'+cid).css({'height': 'auto', 'text-overflow': 'initial', 'overflow':'visible', 'white-space':'normal'})
429 429 });
430 430 $('.compare_select').on('click',function(e){
431 431 var cid = $(this).attr('commit_id');
432 432 $('#row-'+cid).toggleClass('hl', !$('#row-'+cid).hasClass('hl'));
433 433 });
434 434 </script>
435 435 <div class="cs_files_title">
436 436 <span class="cs_files_expand">
437 437 <span id="expand_all_files">Expand All</span> | <span id="collapse_all_files">Collapse All</span>
438 438 </span>
439 439 <h2>
440 440 7 files changed: 55 inserted, 9 deleted
441 441 </h2>
442 442 </div>
443 443 <div class="cs_files">
444 444 <table class="compare_view_files">
445 445
446 446 <tr class="cs_A expand_file" fid="c--efbe5b7a3f13">
447 447 <td class="cs_icon_td">
448 448 <span class="expand_file_icon" fid="c--efbe5b7a3f13"></span>
449 449 </td>
450 450 <td class="cs_icon_td">
451 451 <div class="flag_status not_reviewed hidden"></div>
452 452 </td>
453 453 <td id="a_c--efbe5b7a3f13">
454 454 <a class="compare_view_filepath" href="#a_c--efbe5b7a3f13">
455 455 rhodecode/tests/fixtures/git_diff_rename_file_with_spaces.diff
456 456 </a>
457 457 <span id="diff_c--efbe5b7a3f13" class="diff_links" style="display: none;">
458 458 <a href="/andersonsantos/rhodecode-momentum-fork/diff/rhodecode/tests/fixtures/git_diff_rename_file_with_spaces.diff?diff2=46b3d50315f0f2b1f64485ac95af4f384948f9cb&amp;diff1=b78e2376b986b2cf656a2b4390b09f303291c886&amp;fulldiff=1&amp;diff=diff">
459 459 Unified Diff
460 460 </a>
461 461 |
462 462 <a href="/andersonsantos/rhodecode-momentum-fork/diff-2way/rhodecode/tests/fixtures/git_diff_rename_file_with_spaces.diff?diff2=46b3d50315f0f2b1f64485ac95af4f384948f9cb&amp;diff1=b78e2376b986b2cf656a2b4390b09f303291c886&amp;fulldiff=1&amp;diff=diff">
463 463 Side-by-side Diff
464 464 </a>
465 465 </span>
466 466 </td>
467 467 <td>
468 468 <div class="changes pull-right"><div style="width:100px"><div class="added top-right-rounded-corner-mid bottom-right-rounded-corner-mid top-left-rounded-corner-mid bottom-left-rounded-corner-mid" style="width:100.0%">4</div><div class="deleted top-right-rounded-corner-mid bottom-right-rounded-corner-mid" style="width:0%"></div></div></div>
469 469 <div class="comment-bubble pull-right" data-path="rhodecode/tests/fixtures/git_diff_rename_file_with_spaces.diff">
470 470 <i class="icon-comment"></i>
471 471 </div>
472 472 </td>
473 473 </tr>
474 474 <tr id="tr_c--efbe5b7a3f13">
475 475 <td></td>
476 476 <td></td>
477 477 <td class="injected_diff" colspan="2">
478 478
479 479 <div class="diff-container" id="diff-container-140716195039928">
480 480 <div id="c--efbe5b7a3f13_target" ></div>
481 481 <div id="c--efbe5b7a3f13" class="diffblock margined comm" >
482 482 <div class="code-body">
483 483 <div class="full_f_path" path="rhodecode/tests/fixtures/git_diff_rename_file_with_spaces.diff" style="display: none;"></div>
484 484 <table class="code-difftable">
485 485 <tr class="line context">
486 486 <td class="add-comment-line"><span class="add-comment-content"></span></td>
487 487 <td class="lineno old"><a href="#rhodecodetestsfixturesgit_diff_rename_file_with_spacesdiff_o"></a></td>
488 488 <td class="lineno new"><a href="#rhodecodetestsfixturesgit_diff_rename_file_with_spacesdiff_n"></a></td>
489 489 <td class="code no-comment">
490 490 <pre>new file 100644</pre>
491 491 </td>
492 492 </tr>
493 493 <tr class="line add">
494 494 <td class="add-comment-line"><span class="add-comment-content"><a href="#"><span class="icon-comment-add"></span></a></span></td>
495 495 <td class="lineno old"><a href="#rhodecodetestsfixturesgit_diff_rename_file_with_spacesdiff_o"></a></td>
496 496 <td id="rhodecodetestsfixturesgit_diff_rename_file_with_spacesdiff_n1" class="lineno new"><a href="#rhodecodetestsfixturesgit_diff_rename_file_with_spacesdiff_n1">1</a></td>
497 497 <td class="code">
498 498 <pre>diff --git a/file_with_ spaces.txt b/file_with_ two spaces.txt
499 499 </pre>
500 500 </td>
501 501 </tr>
502 502 <tr class="line add">
503 503 <td class="add-comment-line"><span class="add-comment-content"><a href="#"><span class="icon-comment-add"></span></a></span></td>
504 504 <td class="lineno old"><a href="#rhodecodetestsfixturesgit_diff_rename_file_with_spacesdiff_o"></a></td>
505 505 <td id="rhodecodetestsfixturesgit_diff_rename_file_with_spacesdiff_n2" class="lineno new"><a href="#rhodecodetestsfixturesgit_diff_rename_file_with_spacesdiff_n2">2</a></td>
506 506 <td class="code">
507 507 <pre>similarity index 100%
508 508 </pre>
509 509 </td>
510 510 </tr>
511 511 <tr class="line add">
512 512 <td class="add-comment-line"><span class="add-comment-content"><a href="#"><span class="icon-comment-add"></span></a></span></td>
513 513 <td class="lineno old"><a href="#rhodecodetestsfixturesgit_diff_rename_file_with_spacesdiff_o"></a></td>
514 514 <td id="rhodecodetestsfixturesgit_diff_rename_file_with_spacesdiff_n3" class="lineno new"><a href="#rhodecodetestsfixturesgit_diff_rename_file_with_spacesdiff_n3">3</a></td>
515 515 <td class="code">
516 516 <pre>rename from file_with_ spaces.txt
517 517 </pre>
518 518 </td>
519 519 </tr>
520 520 <tr class="line add">
521 521 <td class="add-comment-line"><span class="add-comment-content"><a href="#"><span class="icon-comment-add"></span></a></span></td>
522 522 <td class="lineno old"><a href="#rhodecodetestsfixturesgit_diff_rename_file_with_spacesdiff_o"></a></td>
523 523 <td id="rhodecodetestsfixturesgit_diff_rename_file_with_spacesdiff_n4" class="lineno new"><a href="#rhodecodetestsfixturesgit_diff_rename_file_with_spacesdiff_n4">4</a></td>
524 524 <td class="code">
525 525 <pre>rename to file_with_ two spaces.txt
526 526 </pre>
527 527 </td>
528 528 </tr>
529 529 <tr class="line context">
530 530 <td class="add-comment-line"><span class="add-comment-content"></span></td>
531 531 <td class="lineno old"><a href="#rhodecodetestsfixturesgit_diff_rename_file_with_spacesdiff_o...">...</a></td>
532 532 <td class="lineno new"><a href="#rhodecodetestsfixturesgit_diff_rename_file_with_spacesdiff_n...">...</a></td>
533 533 <td class="code no-comment">
534 534 <pre> No newline at end of file</pre>
535 535 </td>
536 536 </tr>
537 537 </table>
538 538 </div>
539 539 </div>
540 540 </div>
541 541
542 542 </td>
543 543 </tr>
544 544 <tr class="cs_A expand_file" fid="c--c21377f778f9">
545 545 <td class="cs_icon_td">
546 546 <span class="expand_file_icon" fid="c--c21377f778f9"></span>
547 547 </td>
548 548 <td class="cs_icon_td">
549 549 <div class="flag_status not_reviewed hidden"></div>
550 550 </td>
551 551 <td id="a_c--c21377f778f9">
552 552 <a class="compare_view_filepath" href="#a_c--c21377f778f9">
553 553 rhodecode/tests/fixtures/hg_diff_copy_file_with_spaces.diff
554 554 </a>
555 555 <span id="diff_c--c21377f778f9" class="diff_links" style="display: none;">
556 556 <a href="/andersonsantos/rhodecode-momentum-fork/diff/rhodecode/tests/fixtures/hg_diff_copy_file_with_spaces.diff?diff2=46b3d50315f0f2b1f64485ac95af4f384948f9cb&amp;diff1=b78e2376b986b2cf656a2b4390b09f303291c886&amp;fulldiff=1&amp;diff=diff">
557 557 Unified Diff
558 558 </a>
559 559 |
560 560 <a href="/andersonsantos/rhodecode-momentum-fork/diff-2way/rhodecode/tests/fixtures/hg_diff_copy_file_with_spaces.diff?diff2=46b3d50315f0f2b1f64485ac95af4f384948f9cb&amp;diff1=b78e2376b986b2cf656a2b4390b09f303291c886&amp;fulldiff=1&amp;diff=diff">
561 561 Side-by-side Diff
562 562 </a>
563 563 </span>
564 564 </td>
565 565 <td>
566 566 <div class="changes pull-right"><div style="width:100px"><div class="added top-right-rounded-corner-mid bottom-right-rounded-corner-mid top-left-rounded-corner-mid bottom-left-rounded-corner-mid" style="width:100.0%">3</div><div class="deleted top-right-rounded-corner-mid bottom-right-rounded-corner-mid" style="width:0%"></div></div></div>
567 567 <div class="comment-bubble pull-right" data-path="rhodecode/tests/fixtures/hg_diff_copy_file_with_spaces.diff">
568 568 <i class="icon-comment"></i>
569 569 </div>
570 570 </td>
571 571 </tr>
572 572 <tr id="tr_c--c21377f778f9">
573 573 <td></td>
574 574 <td></td>
575 575 <td class="injected_diff" colspan="2">
576 576
577 577 <div class="diff-container" id="diff-container-140716195038344">
578 578 <div id="c--c21377f778f9_target" ></div>
579 579 <div id="c--c21377f778f9" class="diffblock margined comm" >
580 580 <div class="code-body">
581 581 <div class="full_f_path" path="rhodecode/tests/fixtures/hg_diff_copy_file_with_spaces.diff" style="display: none;"></div>
582 582 <table class="code-difftable">
583 583 <tr class="line context">
584 584 <td class="add-comment-line"><span class="add-comment-content"></span></td>
585 585 <td class="lineno old"><a href="#rhodecodetestsfixtureshg_diff_copy_file_with_spacesdiff_o"></a></td>
586 586 <td class="lineno new"><a href="#rhodecodetestsfixtureshg_diff_copy_file_with_spacesdiff_n"></a></td>
587 587 <td class="code no-comment">
588 588 <pre>new file 100644</pre>
589 589 </td>
590 590 </tr>
591 591 <tr class="line add">
592 592 <td class="add-comment-line"><span class="add-comment-content"><a href="#"><span class="icon-comment-add"></span></a></span></td>
593 593 <td class="lineno old"><a href="#rhodecodetestsfixtureshg_diff_copy_file_with_spacesdiff_o"></a></td>
594 594 <td id="rhodecodetestsfixtureshg_diff_copy_file_with_spacesdiff_n1" class="lineno new"><a href="#rhodecodetestsfixtureshg_diff_copy_file_with_spacesdiff_n1">1</a></td>
595 595 <td class="code">
596 596 <pre>diff --git a/file_changed_without_spaces.txt b/file_copied_ with spaces.txt
597 597 </pre>
598 598 </td>
599 599 </tr>
600 600 <tr class="line add">
601 601 <td class="add-comment-line"><span class="add-comment-content"><a href="#"><span class="icon-comment-add"></span></a></span></td>
602 602 <td class="lineno old"><a href="#rhodecodetestsfixtureshg_diff_copy_file_with_spacesdiff_o"></a></td>
603 603 <td id="rhodecodetestsfixtureshg_diff_copy_file_with_spacesdiff_n2" class="lineno new"><a href="#rhodecodetestsfixtureshg_diff_copy_file_with_spacesdiff_n2">2</a></td>
604 604 <td class="code">
605 605 <pre>copy from file_changed_without_spaces.txt
606 606 </pre>
607 607 </td>
608 608 </tr>
609 609 <tr class="line add">
610 610 <td class="add-comment-line"><span class="add-comment-content"><a href="#"><span class="icon-comment-add"></span></a></span></td>
611 611 <td class="lineno old"><a href="#rhodecodetestsfixtureshg_diff_copy_file_with_spacesdiff_o"></a></td>
612 612 <td id="rhodecodetestsfixtureshg_diff_copy_file_with_spacesdiff_n3" class="lineno new"><a href="#rhodecodetestsfixtureshg_diff_copy_file_with_spacesdiff_n3">3</a></td>
613 613 <td class="code">
614 614 <pre>copy to file_copied_ with spaces.txt
615 615 </pre>
616 616 </td>
617 617 </tr>
618 618 <tr class="line context">
619 619 <td class="add-comment-line"><span class="add-comment-content"></span></td>
620 620 <td class="lineno old"><a href="#rhodecodetestsfixtureshg_diff_copy_file_with_spacesdiff_o...">...</a></td>
621 621 <td class="lineno new"><a href="#rhodecodetestsfixtureshg_diff_copy_file_with_spacesdiff_n...">...</a></td>
622 622 <td class="code no-comment">
623 623 <pre> No newline at end of file</pre>
624 624 </td>
625 625 </tr>
626 626 </table>
627 627 </div>
628 628 </div>
629 629 </div>
630 630
631 631 </td>
632 632 </tr>
633 633 <tr class="cs_A expand_file" fid="c--ee62085ad7a8">
634 634 <td class="cs_icon_td">
635 635 <span class="expand_file_icon" fid="c--ee62085ad7a8"></span>
636 636 </td>
637 637 <td class="cs_icon_td">
638 638 <div class="flag_status not_reviewed hidden"></div>
639 639 </td>
640 640 <td id="a_c--ee62085ad7a8">
641 641 <a class="compare_view_filepath" href="#a_c--ee62085ad7a8">
642 642 rhodecode/tests/fixtures/hg_diff_rename_file_with_spaces.diff
643 643 </a>
644 644 <span id="diff_c--ee62085ad7a8" class="diff_links" style="display: none;">
645 645 <a href="/andersonsantos/rhodecode-momentum-fork/diff/rhodecode/tests/fixtures/hg_diff_rename_file_with_spaces.diff?diff2=46b3d50315f0f2b1f64485ac95af4f384948f9cb&amp;diff1=b78e2376b986b2cf656a2b4390b09f303291c886&amp;fulldiff=1&amp;diff=diff">
646 646 Unified Diff
647 647 </a>
648 648 |
649 649 <a href="/andersonsantos/rhodecode-momentum-fork/diff-2way/rhodecode/tests/fixtures/hg_diff_rename_file_with_spaces.diff?diff2=46b3d50315f0f2b1f64485ac95af4f384948f9cb&amp;diff1=b78e2376b986b2cf656a2b4390b09f303291c886&amp;fulldiff=1&amp;diff=diff">
650 650 Side-by-side Diff
651 651 </a>
652 652 </span>
653 653 </td>
654 654 <td>
655 655 <div class="changes pull-right"><div style="width:100px"><div class="added top-right-rounded-corner-mid bottom-right-rounded-corner-mid top-left-rounded-corner-mid bottom-left-rounded-corner-mid" style="width:100.0%">3</div><div class="deleted top-right-rounded-corner-mid bottom-right-rounded-corner-mid" style="width:0%"></div></div></div>
656 656 <div class="comment-bubble pull-right" data-path="rhodecode/tests/fixtures/hg_diff_rename_file_with_spaces.diff">
657 657 <i class="icon-comment"></i>
658 658 </div>
659 659 </td>
660 660 </tr>
661 661 <tr id="tr_c--ee62085ad7a8">
662 662 <td></td>
663 663 <td></td>
664 664 <td class="injected_diff" colspan="2">
665 665
666 666 <div class="diff-container" id="diff-container-140716195039496">
667 667 <div id="c--ee62085ad7a8_target" ></div>
668 668 <div id="c--ee62085ad7a8" class="diffblock margined comm" >
669 669 <div class="code-body">
670 670 <div class="full_f_path" path="rhodecode/tests/fixtures/hg_diff_rename_file_with_spaces.diff" style="display: none;"></div>
671 671 <table class="code-difftable">
672 672 <tr class="line context">
673 673 <td class="add-comment-line"><span class="add-comment-content"></span></td>
674 674 <td class="lineno old"><a href="#rhodecodetestsfixtureshg_diff_rename_file_with_spacesdiff_o"></a></td>
675 675 <td class="lineno new"><a href="#rhodecodetestsfixtureshg_diff_rename_file_with_spacesdiff_n"></a></td>
676 676 <td class="code no-comment">
677 677 <pre>new file 100644</pre>
678 678 </td>
679 679 </tr>
680 680 <tr class="line add">
681 681 <td class="add-comment-line"><span class="add-comment-content"><a href="#"><span class="icon-comment-add"></span></a></span></td>
682 682 <td class="lineno old"><a href="#rhodecodetestsfixtureshg_diff_rename_file_with_spacesdiff_o"></a></td>
683 683 <td id="rhodecodetestsfixtureshg_diff_rename_file_with_spacesdiff_n1" class="lineno new"><a href="#rhodecodetestsfixtureshg_diff_rename_file_with_spacesdiff_n1">1</a></td>
684 684 <td class="code">
685 685 <pre>diff --git a/file_ with update.txt b/file_changed _.txt
686 686 </pre>
687 687 </td>
688 688 </tr>
689 689 <tr class="line add">
690 690 <td class="add-comment-line"><span class="add-comment-content"><a href="#"><span class="icon-comment-add"></span></a></span></td>
691 691 <td class="lineno old"><a href="#rhodecodetestsfixtureshg_diff_rename_file_with_spacesdiff_o"></a></td>
692 692 <td id="rhodecodetestsfixtureshg_diff_rename_file_with_spacesdiff_n2" class="lineno new"><a href="#rhodecodetestsfixtureshg_diff_rename_file_with_spacesdiff_n2">2</a></td>
693 693 <td class="code">
694 694 <pre>rename from file_ with update.txt
695 695 </pre>
696 696 </td>
697 697 </tr>
698 698 <tr class="line add">
699 699 <td class="add-comment-line"><span class="add-comment-content"><a href="#"><span class="icon-comment-add"></span></a></span></td>
700 700 <td class="lineno old"><a href="#rhodecodetestsfixtureshg_diff_rename_file_with_spacesdiff_o"></a></td>
701 701 <td id="rhodecodetestsfixtureshg_diff_rename_file_with_spacesdiff_n3" class="lineno new"><a href="#rhodecodetestsfixtureshg_diff_rename_file_with_spacesdiff_n3">3</a></td>
702 702 <td class="code">
703 703 <pre>rename to file_changed _.txt</pre>
704 704 </td>
705 705 </tr>
706 706 </table>
707 707 </div>
708 708 </div>
709 709 </div>
710 710
711 711 </td>
712 712 </tr>
713 713
714 714 </table>
715 715 </div>
716 716 </div>
717 717 </div>
718 718
719 719 </td>
720 720 </tr>
721 721 </table>
722 722 </div>
723 723 </div>
724 724 </div>
725 725
726 726
727 727
728 728
729 729 <div id="comment-inline-form-template" style="display: none;">
730 730 <div class="comment-inline-form ac">
731 731 <div class="overlay"><div class="overlay-text">Submitting...</div></div>
732 732 <form action="#" class="inline-form" method="get">
733 733 <div id="edit-container_{1}" class="clearfix">
734 734 <div class="comment-title pull-left">
735 735 Commenting on line {1}.
736 736 </div>
737 737 <div class="comment-help pull-right">
738 738 Comments parsed using <a href="http://docutils.sourceforge.net/docs/user/rst/quickref.html">RST</a> syntax with <span class="tooltip" title="Use @username inside this text to send notification to this RhodeCode user">@mention</span> support.
739 739 </div>
740 740 <div style="clear: both"></div>
741 741 <textarea id="text_{1}" name="text" class="comment-block-ta ac-input"></textarea>
742 742 </div>
743 743 <div id="preview-container_{1}" class="clearfix" style="display: none;">
744 744 <div class="comment-help">
745 745 Comment preview
746 746 </div>
747 747 <div id="preview-box_{1}" class="preview-box"></div>
748 748 </div>
749 749 <div class="comment-button pull-right">
750 750 <input type="hidden" name="f_path" value="{0}">
751 751 <input type="hidden" name="line" value="{1}">
752 752 <div id="preview-btn_{1}" class="btn btn-default">Preview</div>
753 753 <div id="edit-btn_{1}" class="btn" style="display: none;">Edit</div>
754 754 <input class="btn btn-success save-inline-form" id="save" name="save" type="submit" value="Comment" />
755 755 </div>
756 756 <div class="comment-button hide-inline-form-button">
757 757 <input class="btn hide-inline-form" id="hide-inline-form" name="hide-inline-form" type="reset" value="Cancel" />
758 758 </div>
759 759 </form>
760 760 </div>
761 761 </div>
762 762
763 763
764 764
765 765 <div class="comments">
766 766 <div id="inline-comments-container">
767 767
768 768 <h2>0 Pull Request Comments</h2>
769 769
770 770
771 771 </div>
772 772
773 773 </div>
774 774
775 775
776 776
777 777
778 778 <div class="pull-request-merge">
779 779 </div>
780 780 <div class="comments">
781 781 <div class="comment-form ac">
782 782 <form action="/rhodecode-momentum/pull-request-comment/720" id="comments_form" method="POST">
783 783 <div style="display: none;"><input id="csrf_token" name="csrf_token" type="hidden" value="6dbc0b19ac65237df65d57202a3e1f2df4153e38" /></div>
784 784 <div id="edit-container" class="clearfix">
785 785 <div class="comment-title pull-left">
786 786 Create a comment on this Pull Request.
787 787 </div>
788 788 <div class="comment-help pull-right">
789 789 Comments parsed using <a href="http://docutils.sourceforge.net/docs/user/rst/quickref.html">RST</a> syntax with <span class="tooltip" title="Use @username inside this text to send notification to this RhodeCode user">@mention</span> support.
790 790 </div>
791 791 <div style="clear: both"></div>
792 792 <textarea class="comment-block-ta" id="text" name="text"></textarea>
793 793 </div>
794 794
795 795 <div id="preview-container" class="clearfix" style="display: none;">
796 796 <div class="comment-title">
797 797 Comment preview
798 798 </div>
799 799 <div id="preview-box" class="preview-box"></div>
800 800 </div>
801 801
802 802 <div id="comment_form_extras">
803 803 </div>
804 804 <div class="action-button pull-right">
805 805 <div id="preview-btn" class="btn">
806 806 Preview
807 807 </div>
808 808 <div id="edit-btn" class="btn" style="display: none;">
809 809 Edit
810 810 </div>
811 811 <div class="comment-button">
812 812 <input class="btn btn-small btn-success comment-button-input" id="save" name="save" type="submit" value="Comment" />
813 813 </div>
814 814 </div>
815 815 </form>
816 816 </div>
817 817 </div>
818 818 <script>
819 819
820 820 $(document).ready(function() {
821 821
822 822 var cm = initCommentBoxCodeMirror('#text');
823 823
824 824 // main form preview
825 825 $('#preview-btn').on('click', function(e) {
826 826 $('#preview-btn').hide();
827 827 $('#edit-btn').show();
828 828 var _text = cm.getValue();
829 829 if (!_text) {
830 830 return;
831 831 }
832 832 var post_data = {
833 833 'text': _text,
834 834 'renderer': DEFAULT_RENDERER,
835 835 'csrf_token': CSRF_TOKEN
836 836 };
837 837 var previewbox = $('#preview-box');
838 838 previewbox.addClass('unloaded');
839 839 previewbox.html(_gettext('Loading ...'));
840 840 $('#edit-container').hide();
841 841 $('#preview-container').show();
842 842
843 843 var url = pyroutes.url('changeset_comment_preview', {'repo_name': 'rhodecode-momentum'});
844 844
845 845 ajaxPOST(url, post_data, function(o) {
846 846 previewbox.html(o);
847 847 previewbox.removeClass('unloaded');
848 848 });
849 849 });
850 850 $('#edit-btn').on('click', function(e) {
851 851 $('#preview-btn').show();
852 852 $('#edit-btn').hide();
853 853 $('#edit-container').show();
854 854 $('#preview-container').hide();
855 855 });
856 856
857 857 var formatChangeStatus = function(state, escapeMarkup) {
858 858 var originalOption = state.element;
859 859 return '<div class="flag_status ' + $(originalOption).data('status') + ' pull-left"></div>' +
860 860 '<span>' + escapeMarkup(state.text) + '</span>';
861 861 };
862 862
863 863 var formatResult = function(result, container, query, escapeMarkup) {
864 864 return formatChangeStatus(result, escapeMarkup);
865 865 };
866 866
867 867 var formatSelection = function(data, container, escapeMarkup) {
868 868 return formatChangeStatus(data, escapeMarkup);
869 869 };
870 870
871 871 $('#change_status_general').select2({
872 872 placeholder: "Status Review",
873 873 formatResult: formatResult,
874 874 formatSelection: formatSelection,
875 875 containerCssClass: "drop-menu status_box_menu",
876 876 dropdownCssClass: "drop-menu-dropdown",
877 877 dropdownAutoWidth: true,
878 878 minimumResultsForSearch: -1
879 879 });
880 880 });
881 881 </script>
882 882
883 883
884 884 <script type="text/javascript">
885 885 // TODO: switch this to pyroutes
886 886 AJAX_COMMENT_DELETE_URL = "/rhodecode-momentum/pull-request-comment/__COMMENT_ID__/delete";
887 887
888 888 $(function(){
889 889 ReviewerAutoComplete('#user');
890 890
891 891 $('#open_edit_reviewers').on('click', function(e){
892 892 $('#open_edit_reviewers').hide();
893 893 $('#close_edit_reviewers').show();
894 894 $('#add_reviewer_input').show();
895 895 $('.reviewer_member_remove').css('visibility', 'visible');
896 896 });
897 897
898 898 $('#close_edit_reviewers').on('click', function(e){
899 899 $('#open_edit_reviewers').show();
900 900 $('#close_edit_reviewers').hide();
901 901 $('#add_reviewer_input').hide();
902 902 $('.reviewer_member_remove').css('visibility', 'hidden');
903 903 });
904 904
905 905 $('.show-inline-comments').on('change', function(e){
906 906 var show = 'none';
907 907 var target = e.currentTarget;
908 908 if(target.checked){
909 909 show = ''
910 910 }
911 911 var boxid = $(target).attr('id_for');
912 912 var comments = $('#{0} .inline-comments'.format(boxid));
913 913 var fn_display = function(idx){
914 914 $(this).css('display', show);
915 915 };
916 916 $(comments).each(fn_display);
917 917 var btns = $('#{0} .inline-comments-button'.format(boxid));
918 918 $(btns).each(fn_display);
919 919 });
920 920
921 921 var commentTotals = {};
922 922 $.each(file_comments, function(i, comment) {
923 923 var path = $(comment).attr('path');
924 924 var comms = $(comment).children().length;
925 925 if (path in commentTotals) {
926 926 commentTotals[path] += comms;
927 927 } else {
928 928 commentTotals[path] = comms;
929 929 }
930 930 });
931 931 $.each(commentTotals, function(path, total) {
932 932 var elem = $('.comment-bubble[data-path="'+ path +'"]')
933 933 elem.css('visibility', 'visible');
934 934 elem.html(elem.html() + ' ' + total );
935 935 });
936 936
937 937 $('#merge_pull_request_form').submit(function() {
938 938 if (!$('#merge_pull_request').attr('disabled')) {
939 939 $('#merge_pull_request').attr('disabled', 'disabled');
940 940 }
941 941 return true;
942 942 });
943 943
944 944 $('#update_pull_request').on('click', function(e){
945 945 updateReviewers(undefined, "rhodecode-momentum", "720");
946 946 });
947 947
948 948 $('#update_commits').on('click', function(e){
949 949 updateCommits("rhodecode-momentum", "720");
950 950 });
951 951
952 $('#close_pull_request').on('click', function(e){
953 closePullRequest("rhodecode-momentum", "720");
954 });
955 952 })
956 953 </script>
957 954
958 955 </div>
959 956 </div></div>
960 957
961 958 </div>
962 959
963 960
964 961 </%def>
@@ -1,865 +1,860 b''
1 1 <%inherit file="/base/base.mako"/>
2 2 <%namespace name="base" file="/base/base.mako"/>
3 3
4 4 <%def name="title()">
5 5 ${_('%s Pull Request #%s') % (c.repo_name, c.pull_request.pull_request_id)}
6 6 %if c.rhodecode_name:
7 7 &middot; ${h.branding(c.rhodecode_name)}
8 8 %endif
9 9 </%def>
10 10
11 11 <%def name="breadcrumbs_links()">
12 12 <span id="pr-title">
13 13 ${c.pull_request.title}
14 14 %if c.pull_request.is_closed():
15 15 (${_('Closed')})
16 16 %endif
17 17 </span>
18 18 <div id="pr-title-edit" class="input" style="display: none;">
19 19 ${h.text('pullrequest_title', id_="pr-title-input", class_="large", value=c.pull_request.title)}
20 20 </div>
21 21 </%def>
22 22
23 23 <%def name="menu_bar_nav()">
24 24 ${self.menu_items(active='repositories')}
25 25 </%def>
26 26
27 27 <%def name="menu_bar_subnav()">
28 28 ${self.repo_menu(active='showpullrequest')}
29 29 </%def>
30 30
31 31 <%def name="main()">
32 32
33 33 <script type="text/javascript">
34 34 // TODO: marcink switch this to pyroutes
35 35 AJAX_COMMENT_DELETE_URL = "${url('pullrequest_comment_delete',repo_name=c.repo_name,comment_id='__COMMENT_ID__')}";
36 36 templateContext.pull_request_data.pull_request_id = ${c.pull_request.pull_request_id};
37 37 </script>
38 38 <div class="box">
39 39
40 40 <div class="title">
41 41 ${self.repo_page_title(c.rhodecode_db_repo)}
42 42 </div>
43 43
44 44 ${self.breadcrumbs()}
45 45
46 46 <div class="box pr-summary">
47 47
48 48 <div class="summary-details block-left">
49 49 <% summary = lambda n:{False:'summary-short'}.get(n) %>
50 50 <div class="pr-details-title">
51 51 <a href="${h.route_path('pull_requests_global', pull_request_id=c.pull_request.pull_request_id)}">${_('Pull request #%s') % c.pull_request.pull_request_id}</a> ${_('From')} ${h.format_date(c.pull_request.created_on)}
52 52 %if c.allowed_to_update:
53 53 <div id="delete_pullrequest" class="pull-right action_button ${'' if c.allowed_to_delete else 'disabled' }" style="clear:inherit;padding: 0">
54 54 % if c.allowed_to_delete:
55 55 ${h.secure_form(url('pullrequest_delete', repo_name=c.pull_request.target_repo.repo_name, pull_request_id=c.pull_request.pull_request_id),method='delete')}
56 56 ${h.submit('remove_%s' % c.pull_request.pull_request_id, _('Delete'),
57 57 class_="btn btn-link btn-danger no-margin",onclick="return confirm('"+_('Confirm to delete this pull request')+"');")}
58 58 ${h.end_form()}
59 59 % else:
60 60 ${_('Delete')}
61 61 % endif
62 62 </div>
63 63 <div id="open_edit_pullrequest" class="pull-right action_button">${_('Edit')}</div>
64 64 <div id="close_edit_pullrequest" class="pull-right action_button" style="display: none;padding: 0">${_('Cancel')}</div>
65 65 %endif
66 66 </div>
67 67
68 68 <div id="summary" class="fields pr-details-content">
69 69 <div class="field">
70 70 <div class="label-summary">
71 71 <label>${_('Source')}:</label>
72 72 </div>
73 73 <div class="input">
74 74 <div class="pr-origininfo">
75 75 ## branch link is only valid if it is a branch
76 76 <span class="tag">
77 77 %if c.pull_request.source_ref_parts.type == 'branch':
78 78 <a href="${h.url('changelog_home', repo_name=c.pull_request.source_repo.repo_name, branch=c.pull_request.source_ref_parts.name)}">${c.pull_request.source_ref_parts.type}: ${c.pull_request.source_ref_parts.name}</a>
79 79 %else:
80 80 ${c.pull_request.source_ref_parts.type}: ${c.pull_request.source_ref_parts.name}
81 81 %endif
82 82 </span>
83 83 <span class="clone-url">
84 84 <a href="${h.route_path('repo_summary', repo_name=c.pull_request.source_repo.repo_name)}">${c.pull_request.source_repo.clone_url()}</a>
85 85 </span>
86 86 <br/>
87 87 % if c.ancestor_commit:
88 88 ${_('Common ancestor')}:
89 89 <code><a href="${h.url('changeset_home', repo_name=c.target_repo.repo_name, revision=c.ancestor_commit.raw_id)}">${h.show_id(c.ancestor_commit)}</a></code>
90 90 % endif
91 91 </div>
92 92 <div class="pr-pullinfo">
93 93 %if h.is_hg(c.pull_request.source_repo):
94 94 <input type="text" class="input-monospace" value="hg pull -r ${h.short_id(c.source_ref)} ${c.pull_request.source_repo.clone_url()}" readonly="readonly">
95 95 %elif h.is_git(c.pull_request.source_repo):
96 96 <input type="text" class="input-monospace" value="git pull ${c.pull_request.source_repo.clone_url()} ${c.pull_request.source_ref_parts.name}" readonly="readonly">
97 97 %endif
98 98 </div>
99 99 </div>
100 100 </div>
101 101 <div class="field">
102 102 <div class="label-summary">
103 103 <label>${_('Target')}:</label>
104 104 </div>
105 105 <div class="input">
106 106 <div class="pr-targetinfo">
107 107 ## branch link is only valid if it is a branch
108 108 <span class="tag">
109 109 %if c.pull_request.target_ref_parts.type == 'branch':
110 110 <a href="${h.url('changelog_home', repo_name=c.pull_request.target_repo.repo_name, branch=c.pull_request.target_ref_parts.name)}">${c.pull_request.target_ref_parts.type}: ${c.pull_request.target_ref_parts.name}</a>
111 111 %else:
112 112 ${c.pull_request.target_ref_parts.type}: ${c.pull_request.target_ref_parts.name}
113 113 %endif
114 114 </span>
115 115 <span class="clone-url">
116 116 <a href="${h.route_path('repo_summary', repo_name=c.pull_request.target_repo.repo_name)}">${c.pull_request.target_repo.clone_url()}</a>
117 117 </span>
118 118 </div>
119 119 </div>
120 120 </div>
121 121
122 122 ## Link to the shadow repository.
123 123 <div class="field">
124 124 <div class="label-summary">
125 125 <label>${_('Merge')}:</label>
126 126 </div>
127 127 <div class="input">
128 128 % if not c.pull_request.is_closed() and c.pull_request.shadow_merge_ref:
129 129 <div class="pr-mergeinfo">
130 130 %if h.is_hg(c.pull_request.target_repo):
131 131 <input type="text" class="input-monospace" value="hg clone -u ${c.pull_request.shadow_merge_ref.name} ${c.shadow_clone_url} pull-request-${c.pull_request.pull_request_id}" readonly="readonly">
132 132 %elif h.is_git(c.pull_request.target_repo):
133 133 <input type="text" class="input-monospace" value="git clone --branch ${c.pull_request.shadow_merge_ref.name} ${c.shadow_clone_url} pull-request-${c.pull_request.pull_request_id}" readonly="readonly">
134 134 %endif
135 135 </div>
136 136 % else:
137 137 <div class="">
138 138 ${_('Shadow repository data not available')}.
139 139 </div>
140 140 % endif
141 141 </div>
142 142 </div>
143 143
144 144 <div class="field">
145 145 <div class="label-summary">
146 146 <label>${_('Review')}:</label>
147 147 </div>
148 148 <div class="input">
149 149 %if c.pull_request_review_status:
150 150 <div class="${'flag_status %s' % c.pull_request_review_status} tooltip pull-left"></div>
151 151 <span class="changeset-status-lbl tooltip">
152 152 %if c.pull_request.is_closed():
153 153 ${_('Closed')},
154 154 %endif
155 155 ${h.commit_status_lbl(c.pull_request_review_status)}
156 156 </span>
157 157 - ${ungettext('calculated based on %s reviewer vote', 'calculated based on %s reviewers votes', len(c.pull_request_reviewers)) % len(c.pull_request_reviewers)}
158 158 %endif
159 159 </div>
160 160 </div>
161 161 <div class="field">
162 162 <div class="pr-description-label label-summary">
163 163 <label>${_('Description')}:</label>
164 164 </div>
165 165 <div id="pr-desc" class="input">
166 166 <div class="pr-description">${h.urlify_commit_message(c.pull_request.description, c.repo_name)}</div>
167 167 </div>
168 168 <div id="pr-desc-edit" class="input textarea editor" style="display: none;">
169 169 <textarea id="pr-description-input" size="30">${c.pull_request.description}</textarea>
170 170 </div>
171 171 </div>
172 172
173 173 <div class="field">
174 174 <div class="label-summary">
175 175 <label>${_('Versions')}:</label>
176 176 </div>
177 177
178 178 <% outdated_comm_count_ver = len(c.inline_versions[None]['outdated']) %>
179 179 <% general_outdated_comm_count_ver = len(c.comment_versions[None]['outdated']) %>
180 180
181 181 <div class="pr-versions">
182 182 % if c.show_version_changes:
183 183 <% outdated_comm_count_ver = len(c.inline_versions[c.at_version_num]['outdated']) %>
184 184 <% general_outdated_comm_count_ver = len(c.comment_versions[c.at_version_num]['outdated']) %>
185 185 <a id="show-pr-versions" class="input" onclick="return versionController.toggleVersionView(this)" href="#show-pr-versions"
186 186 data-toggle-on="${ungettext('{} version available for this pull request, show it.', '{} versions available for this pull request, show them.', len(c.versions)).format(len(c.versions))}"
187 187 data-toggle-off="${_('Hide all versions of this pull request')}">
188 188 ${ungettext('{} version available for this pull request, show it.', '{} versions available for this pull request, show them.', len(c.versions)).format(len(c.versions))}
189 189 </a>
190 190 <table>
191 191 ## SHOW ALL VERSIONS OF PR
192 192 <% ver_pr = None %>
193 193
194 194 % for data in reversed(list(enumerate(c.versions, 1))):
195 195 <% ver_pos = data[0] %>
196 196 <% ver = data[1] %>
197 197 <% ver_pr = ver.pull_request_version_id %>
198 198 <% display_row = '' if c.at_version and (c.at_version_num == ver_pr or c.from_version_num == ver_pr) else 'none' %>
199 199
200 200 <tr class="version-pr" style="display: ${display_row}">
201 201 <td>
202 202 <code>
203 203 <a href="${h.url.current(version=ver_pr or 'latest')}">v${ver_pos}</a>
204 204 </code>
205 205 </td>
206 206 <td>
207 207 <input ${'checked="checked"' if c.from_version_num == ver_pr else ''} class="compare-radio-button" type="radio" name="ver_source" value="${ver_pr or 'latest'}" data-ver-pos="${ver_pos}"/>
208 208 <input ${'checked="checked"' if c.at_version_num == ver_pr else ''} class="compare-radio-button" type="radio" name="ver_target" value="${ver_pr or 'latest'}" data-ver-pos="${ver_pos}"/>
209 209 </td>
210 210 <td>
211 211 <% review_status = c.review_versions[ver_pr].status if ver_pr in c.review_versions else 'not_reviewed' %>
212 212 <div class="${'flag_status %s' % review_status} tooltip pull-left" title="${_('Your review status at this version')}">
213 213 </div>
214 214 </td>
215 215 <td>
216 216 % if c.at_version_num != ver_pr:
217 217 <i class="icon-comment"></i>
218 218 <code class="tooltip" title="${_('Comment from pull request version {0}, general:{1} inline:{2}').format(ver_pos, len(c.comment_versions[ver_pr]['at']), len(c.inline_versions[ver_pr]['at']))}">
219 219 G:${len(c.comment_versions[ver_pr]['at'])} / I:${len(c.inline_versions[ver_pr]['at'])}
220 220 </code>
221 221 % endif
222 222 </td>
223 223 <td>
224 224 ##<code>${ver.source_ref_parts.commit_id[:6]}</code>
225 225 </td>
226 226 <td>
227 227 ${h.age_component(ver.updated_on, time_is_local=True)}
228 228 </td>
229 229 </tr>
230 230 % endfor
231 231
232 232 <tr>
233 233 <td colspan="6">
234 234 <button id="show-version-diff" onclick="return versionController.showVersionDiff()" class="btn btn-sm" style="display: none"
235 235 data-label-text-locked="${_('select versions to show changes')}"
236 236 data-label-text-diff="${_('show changes between versions')}"
237 237 data-label-text-show="${_('show pull request for this version')}"
238 238 >
239 239 ${_('select versions to show changes')}
240 240 </button>
241 241 </td>
242 242 </tr>
243 243
244 244 ## show comment/inline comments summary
245 245 <%def name="comments_summary()">
246 246 <tr>
247 247 <td colspan="6" class="comments-summary-td">
248 248
249 249 % if c.at_version:
250 250 <% inline_comm_count_ver = len(c.inline_versions[c.at_version_num]['display']) %>
251 251 <% general_comm_count_ver = len(c.comment_versions[c.at_version_num]['display']) %>
252 252 ${_('Comments at this version')}:
253 253 % else:
254 254 <% inline_comm_count_ver = len(c.inline_versions[c.at_version_num]['until']) %>
255 255 <% general_comm_count_ver = len(c.comment_versions[c.at_version_num]['until']) %>
256 256 ${_('Comments for this pull request')}:
257 257 % endif
258 258
259 259
260 260 %if general_comm_count_ver:
261 261 <a href="#comments">${_("%d General ") % general_comm_count_ver}</a>
262 262 %else:
263 263 ${_("%d General ") % general_comm_count_ver}
264 264 %endif
265 265
266 266 %if inline_comm_count_ver:
267 267 , <a href="#" onclick="return Rhodecode.comments.nextComment();" id="inline-comments-counter">${_("%d Inline") % inline_comm_count_ver}</a>
268 268 %else:
269 269 , ${_("%d Inline") % inline_comm_count_ver}
270 270 %endif
271 271
272 272 %if outdated_comm_count_ver:
273 273 , <a href="#" onclick="showOutdated(); Rhodecode.comments.nextOutdatedComment(); return false;">${_("%d Outdated") % outdated_comm_count_ver}</a>
274 274 <a href="#" class="showOutdatedComments" onclick="showOutdated(this); return false;"> | ${_('show outdated comments')}</a>
275 275 <a href="#" class="hideOutdatedComments" style="display: none" onclick="hideOutdated(this); return false;"> | ${_('hide outdated comments')}</a>
276 276 %else:
277 277 , ${_("%d Outdated") % outdated_comm_count_ver}
278 278 %endif
279 279 </td>
280 280 </tr>
281 281 </%def>
282 282 ${comments_summary()}
283 283 </table>
284 284 % else:
285 285 <div class="input">
286 286 ${_('Pull request versions not available')}.
287 287 </div>
288 288 <div>
289 289 <table>
290 290 ${comments_summary()}
291 291 </table>
292 292 </div>
293 293 % endif
294 294 </div>
295 295 </div>
296 296
297 297 <div id="pr-save" class="field" style="display: none;">
298 298 <div class="label-summary"></div>
299 299 <div class="input">
300 300 <span id="edit_pull_request" class="btn btn-small no-margin">${_('Save Changes')}</span>
301 301 </div>
302 302 </div>
303 303 </div>
304 304 </div>
305 305 <div>
306 306 ## AUTHOR
307 307 <div class="reviewers-title block-right">
308 308 <div class="pr-details-title">
309 309 ${_('Author of this pull request')}
310 310 </div>
311 311 </div>
312 312 <div class="block-right pr-details-content reviewers">
313 313 <ul class="group_members">
314 314 <li>
315 315 ${self.gravatar_with_user(c.pull_request.author.email, 16)}
316 316 </li>
317 317 </ul>
318 318 </div>
319 319
320 320 ## REVIEW RULES
321 321 <div id="review_rules" style="display: none" class="reviewers-title block-right">
322 322 <div class="pr-details-title">
323 323 ${_('Reviewer rules')}
324 324 %if c.allowed_to_update:
325 325 <span id="close_edit_reviewers" class="block-right action_button last-item" style="display: none;">${_('Close')}</span>
326 326 %endif
327 327 </div>
328 328 <div class="pr-reviewer-rules">
329 329 ## review rules will be appended here, by default reviewers logic
330 330 </div>
331 331 <input id="review_data" type="hidden" name="review_data" value="">
332 332 </div>
333 333
334 334 ## REVIEWERS
335 335 <div class="reviewers-title block-right">
336 336 <div class="pr-details-title">
337 337 ${_('Pull request reviewers')}
338 338 %if c.allowed_to_update:
339 339 <span id="open_edit_reviewers" class="block-right action_button last-item">${_('Edit')}</span>
340 340 %endif
341 341 </div>
342 342 </div>
343 343 <div id="reviewers" class="block-right pr-details-content reviewers">
344 344 ## members goes here !
345 345 <input type="hidden" name="__start__" value="review_members:sequence">
346 346 <ul id="review_members" class="group_members">
347 347 %for member,reasons,mandatory,status in c.pull_request_reviewers:
348 348 <li id="reviewer_${member.user_id}" class="reviewer_entry">
349 349 <div class="reviewers_member">
350 350 <div class="reviewer_status tooltip" title="${h.tooltip(h.commit_status_lbl(status[0][1].status if status else 'not_reviewed'))}">
351 351 <div class="${'flag_status %s' % (status[0][1].status if status else 'not_reviewed')} pull-left reviewer_member_status"></div>
352 352 </div>
353 353 <div id="reviewer_${member.user_id}_name" class="reviewer_name">
354 354 ${self.gravatar_with_user(member.email, 16)}
355 355 </div>
356 356 <input type="hidden" name="__start__" value="reviewer:mapping">
357 357 <input type="hidden" name="__start__" value="reasons:sequence">
358 358 %for reason in reasons:
359 359 <div class="reviewer_reason">- ${reason}</div>
360 360 <input type="hidden" name="reason" value="${reason}">
361 361
362 362 %endfor
363 363 <input type="hidden" name="__end__" value="reasons:sequence">
364 364 <input id="reviewer_${member.user_id}_input" type="hidden" value="${member.user_id}" name="user_id" />
365 365 <input type="hidden" name="mandatory" value="${mandatory}"/>
366 366 <input type="hidden" name="__end__" value="reviewer:mapping">
367 367 % if mandatory:
368 368 <div class="reviewer_member_mandatory_remove">
369 369 <i class="icon-remove-sign"></i>
370 370 </div>
371 371 <div class="reviewer_member_mandatory">
372 372 <i class="icon-lock" title="Mandatory reviewer"></i>
373 373 </div>
374 374 % else:
375 375 %if c.allowed_to_update:
376 376 <div class="reviewer_member_remove action_button" onclick="reviewersController.removeReviewMember(${member.user_id}, true)" style="visibility: hidden;">
377 377 <i class="icon-remove-sign" ></i>
378 378 </div>
379 379 %endif
380 380 % endif
381 381 </div>
382 382 </li>
383 383 %endfor
384 384 </ul>
385 385 <input type="hidden" name="__end__" value="review_members:sequence">
386 386
387 387 %if not c.pull_request.is_closed():
388 388 <div id="add_reviewer" class="ac" style="display: none;">
389 389 %if c.allowed_to_update:
390 390 % if not c.forbid_adding_reviewers:
391 391 <div id="add_reviewer_input" class="reviewer_ac">
392 392 ${h.text('user', class_='ac-input', placeholder=_('Add reviewer or reviewer group'))}
393 393 <div id="reviewers_container"></div>
394 394 </div>
395 395 % endif
396 396 <div class="pull-right">
397 397 <button id="update_pull_request" class="btn btn-small no-margin">${_('Save Changes')}</button>
398 398 </div>
399 399 %endif
400 400 </div>
401 401 %endif
402 402 </div>
403 403 </div>
404 404 </div>
405 405 <div class="box">
406 406 ##DIFF
407 407 <div class="table" >
408 408 <div id="changeset_compare_view_content">
409 409 ##CS
410 410 % if c.missing_requirements:
411 411 <div class="box">
412 412 <div class="alert alert-warning">
413 413 <div>
414 414 <strong>${_('Missing requirements:')}</strong>
415 415 ${_('These commits cannot be displayed, because this repository uses the Mercurial largefiles extension, which was not enabled.')}
416 416 </div>
417 417 </div>
418 418 </div>
419 419 % elif c.missing_commits:
420 420 <div class="box">
421 421 <div class="alert alert-warning">
422 422 <div>
423 423 <strong>${_('Missing commits')}:</strong>
424 424 ${_('This pull request cannot be displayed, because one or more commits no longer exist in the source repository.')}
425 425 ${_('Please update this pull request, push the commits back into the source repository, or consider closing this pull request.')}
426 426 </div>
427 427 </div>
428 428 </div>
429 429 % endif
430 430
431 431 <div class="compare_view_commits_title">
432 432 % if not c.compare_mode:
433 433
434 434 % if c.at_version_pos:
435 435 <h4>
436 436 ${_('Showing changes at v%d, commenting is disabled.') % c.at_version_pos}
437 437 </h4>
438 438 % endif
439 439
440 440 <div class="pull-left">
441 441 <div class="btn-group">
442 442 <a
443 443 class="btn"
444 444 href="#"
445 445 onclick="$('.compare_select').show();$('.compare_select_hidden').hide(); return false">
446 446 ${ungettext('Expand %s commit','Expand %s commits', len(c.commit_ranges)) % len(c.commit_ranges)}
447 447 </a>
448 448 <a
449 449 class="btn"
450 450 href="#"
451 451 onclick="$('.compare_select').hide();$('.compare_select_hidden').show(); return false">
452 452 ${ungettext('Collapse %s commit','Collapse %s commits', len(c.commit_ranges)) % len(c.commit_ranges)}
453 453 </a>
454 454 </div>
455 455 </div>
456 456
457 457 <div class="pull-right">
458 458 % if c.allowed_to_update and not c.pull_request.is_closed():
459 459 <a id="update_commits" class="btn btn-primary no-margin pull-right">${_('Update commits')}</a>
460 460 % else:
461 461 <a class="tooltip btn disabled pull-right" disabled="disabled" title="${_('Update is disabled for current view')}">${_('Update commits')}</a>
462 462 % endif
463 463
464 464 </div>
465 465 % endif
466 466 </div>
467 467
468 468 % if not c.missing_commits:
469 469 % if c.compare_mode:
470 470 % if c.at_version:
471 471 <h4>
472 472 ${_('Commits and changes between v{ver_from} and {ver_to} of this pull request, commenting is disabled').format(ver_from=c.from_version_pos, ver_to=c.at_version_pos if c.at_version_pos else 'latest')}:
473 473 </h4>
474 474
475 475 <div class="subtitle-compare">
476 476 ${_('commits added: {}, removed: {}').format(len(c.commit_changes_summary.added), len(c.commit_changes_summary.removed))}
477 477 </div>
478 478
479 479 <div class="container">
480 480 <table class="rctable compare_view_commits">
481 481 <tr>
482 482 <th></th>
483 483 <th>${_('Time')}</th>
484 484 <th>${_('Author')}</th>
485 485 <th>${_('Commit')}</th>
486 486 <th></th>
487 487 <th>${_('Description')}</th>
488 488 </tr>
489 489
490 490 % for c_type, commit in c.commit_changes:
491 491 % if c_type in ['a', 'r']:
492 492 <%
493 493 if c_type == 'a':
494 494 cc_title = _('Commit added in displayed changes')
495 495 elif c_type == 'r':
496 496 cc_title = _('Commit removed in displayed changes')
497 497 else:
498 498 cc_title = ''
499 499 %>
500 500 <tr id="row-${commit.raw_id}" commit_id="${commit.raw_id}" class="compare_select">
501 501 <td>
502 502 <div class="commit-change-indicator color-${c_type}-border">
503 503 <div class="commit-change-content color-${c_type} tooltip" title="${cc_title}">
504 504 ${c_type.upper()}
505 505 </div>
506 506 </div>
507 507 </td>
508 508 <td class="td-time">
509 509 ${h.age_component(commit.date)}
510 510 </td>
511 511 <td class="td-user">
512 512 ${base.gravatar_with_user(commit.author, 16)}
513 513 </td>
514 514 <td class="td-hash">
515 515 <code>
516 516 <a href="${h.url('changeset_home', repo_name=c.target_repo.repo_name, revision=commit.raw_id)}">
517 517 r${commit.revision}:${h.short_id(commit.raw_id)}
518 518 </a>
519 519 ${h.hidden('revisions', commit.raw_id)}
520 520 </code>
521 521 </td>
522 522 <td class="expand_commit" data-commit-id="${commit.raw_id}" title="${_( 'Expand commit message')}">
523 523 <div class="show_more_col">
524 524 <i class="show_more"></i>
525 525 </div>
526 526 </td>
527 527 <td class="mid td-description">
528 528 <div class="log-container truncate-wrap">
529 529 <div class="message truncate" id="c-${commit.raw_id}" data-message-raw="${commit.message}">
530 530 ${h.urlify_commit_message(commit.message, c.repo_name)}
531 531 </div>
532 532 </div>
533 533 </td>
534 534 </tr>
535 535 % endif
536 536 % endfor
537 537 </table>
538 538 </div>
539 539
540 540 <script>
541 541 $('.expand_commit').on('click',function(e){
542 542 var target_expand = $(this);
543 543 var cid = target_expand.data('commitId');
544 544
545 545 if (target_expand.hasClass('open')){
546 546 $('#c-'+cid).css({
547 547 'height': '1.5em',
548 548 'white-space': 'nowrap',
549 549 'text-overflow': 'ellipsis',
550 550 'overflow':'hidden'
551 551 });
552 552 target_expand.removeClass('open');
553 553 }
554 554 else {
555 555 $('#c-'+cid).css({
556 556 'height': 'auto',
557 557 'white-space': 'pre-line',
558 558 'text-overflow': 'initial',
559 559 'overflow':'visible'
560 560 });
561 561 target_expand.addClass('open');
562 562 }
563 563 });
564 564 </script>
565 565
566 566 % endif
567 567
568 568 % else:
569 569 <%include file="/compare/compare_commits.mako" />
570 570 % endif
571 571
572 572 <div class="cs_files">
573 573 <%namespace name="cbdiffs" file="/codeblocks/diffs.mako"/>
574 574 ${cbdiffs.render_diffset_menu()}
575 575 ${cbdiffs.render_diffset(
576 576 c.diffset, use_comments=True,
577 577 collapse_when_files_over=30,
578 578 disable_new_comments=not c.allowed_to_comment,
579 579 deleted_files_comments=c.deleted_files_comments)}
580 580 </div>
581 581 % else:
582 582 ## skipping commits we need to clear the view for missing commits
583 583 <div style="clear:both;"></div>
584 584 % endif
585 585
586 586 </div>
587 587 </div>
588 588
589 589 ## template for inline comment form
590 590 <%namespace name="comment" file="/changeset/changeset_file_comment.mako"/>
591 591
592 592 ## render general comments
593 593
594 594 <div id="comment-tr-show">
595 595 <div class="comment">
596 596 % if general_outdated_comm_count_ver:
597 597 <div class="meta">
598 598 % if general_outdated_comm_count_ver == 1:
599 599 ${_('there is {num} general comment from older versions').format(num=general_outdated_comm_count_ver)},
600 600 <a href="#show-hidden-comments" onclick="$('.comment-general.comment-outdated').show(); $(this).parent().hide(); return false;">${_('show it')}</a>
601 601 % else:
602 602 ${_('there are {num} general comments from older versions').format(num=general_outdated_comm_count_ver)},
603 603 <a href="#show-hidden-comments" onclick="$('.comment-general.comment-outdated').show(); $(this).parent().hide(); return false;">${_('show them')}</a>
604 604 % endif
605 605 </div>
606 606 % endif
607 607 </div>
608 608 </div>
609 609
610 610 ${comment.generate_comments(c.comments, include_pull_request=True, is_pull_request=True)}
611 611
612 612 % if not c.pull_request.is_closed():
613 613 ## merge status, and merge action
614 614 <div class="pull-request-merge">
615 615 <%include file="/pullrequests/pullrequest_merge_checks.mako"/>
616 616 </div>
617 617
618 618 ## main comment form and it status
619 619 ${comment.comments(h.url('pullrequest_comment', repo_name=c.repo_name,
620 620 pull_request_id=c.pull_request.pull_request_id),
621 621 c.pull_request_review_status,
622 622 is_pull_request=True, change_status=c.allowed_to_change_status)}
623 623 %endif
624 624
625 625 <script type="text/javascript">
626 626 if (location.hash) {
627 627 var result = splitDelimitedHash(location.hash);
628 628 var line = $('html').find(result.loc);
629 629 // show hidden comments if we use location.hash
630 630 if (line.hasClass('comment-general')) {
631 631 $(line).show();
632 632 } else if (line.hasClass('comment-inline')) {
633 633 $(line).show();
634 634 var $cb = $(line).closest('.cb');
635 635 $cb.removeClass('cb-collapsed')
636 636 }
637 637 if (line.length > 0){
638 638 offsetScroll(line, 70);
639 639 }
640 640 }
641 641
642 642 versionController = new VersionController();
643 643 versionController.init();
644 644
645 645 reviewersController = new ReviewersController();
646 646
647 647 $(function(){
648 648
649 649 // custom code mirror
650 650 var codeMirrorInstance = initPullRequestsCodeMirror('#pr-description-input');
651 651
652 652 var PRDetails = {
653 653 editButton: $('#open_edit_pullrequest'),
654 654 closeButton: $('#close_edit_pullrequest'),
655 655 deleteButton: $('#delete_pullrequest'),
656 656 viewFields: $('#pr-desc, #pr-title'),
657 657 editFields: $('#pr-desc-edit, #pr-title-edit, #pr-save'),
658 658
659 659 init: function() {
660 660 var that = this;
661 661 this.editButton.on('click', function(e) { that.edit(); });
662 662 this.closeButton.on('click', function(e) { that.view(); });
663 663 },
664 664
665 665 edit: function(event) {
666 666 this.viewFields.hide();
667 667 this.editButton.hide();
668 668 this.deleteButton.hide();
669 669 this.closeButton.show();
670 670 this.editFields.show();
671 671 codeMirrorInstance.refresh();
672 672 },
673 673
674 674 view: function(event) {
675 675 this.editButton.show();
676 676 this.deleteButton.show();
677 677 this.editFields.hide();
678 678 this.closeButton.hide();
679 679 this.viewFields.show();
680 680 }
681 681 };
682 682
683 683 var ReviewersPanel = {
684 684 editButton: $('#open_edit_reviewers'),
685 685 closeButton: $('#close_edit_reviewers'),
686 686 addButton: $('#add_reviewer'),
687 687 removeButtons: $('.reviewer_member_remove,.reviewer_member_mandatory_remove,.reviewer_member_mandatory'),
688 688
689 689 init: function() {
690 690 var self = this;
691 691 this.editButton.on('click', function(e) { self.edit(); });
692 692 this.closeButton.on('click', function(e) { self.close(); });
693 693 },
694 694
695 695 edit: function(event) {
696 696 this.editButton.hide();
697 697 this.closeButton.show();
698 698 this.addButton.show();
699 699 this.removeButtons.css('visibility', 'visible');
700 700 // review rules
701 701 reviewersController.loadReviewRules(
702 702 ${c.pull_request.reviewer_data_json | n});
703 703 },
704 704
705 705 close: function(event) {
706 706 this.editButton.show();
707 707 this.closeButton.hide();
708 708 this.addButton.hide();
709 709 this.removeButtons.css('visibility', 'hidden');
710 710 // hide review rules
711 711 reviewersController.hideReviewRules()
712 712 }
713 713 };
714 714
715 715 PRDetails.init();
716 716 ReviewersPanel.init();
717 717
718 718 showOutdated = function(self){
719 719 $('.comment-inline.comment-outdated').show();
720 720 $('.filediff-outdated').show();
721 721 $('.showOutdatedComments').hide();
722 722 $('.hideOutdatedComments').show();
723 723 };
724 724
725 725 hideOutdated = function(self){
726 726 $('.comment-inline.comment-outdated').hide();
727 727 $('.filediff-outdated').hide();
728 728 $('.hideOutdatedComments').hide();
729 729 $('.showOutdatedComments').show();
730 730 };
731 731
732 732 refreshMergeChecks = function(){
733 733 var loadUrl = "${h.url.current(merge_checks=1)}";
734 734 $('.pull-request-merge').css('opacity', 0.3);
735 735 $('.action-buttons-extra').css('opacity', 0.3);
736 736
737 737 $('.pull-request-merge').load(
738 738 loadUrl, function() {
739 739 $('.pull-request-merge').css('opacity', 1);
740 740
741 741 $('.action-buttons-extra').css('opacity', 1);
742 742 injectCloseAction();
743 743 }
744 744 );
745 745 };
746 746
747 747 injectCloseAction = function() {
748 748 var closeAction = $('#close-pull-request-action').html();
749 749 var $actionButtons = $('.action-buttons-extra');
750 750 // clear the action before
751 751 $actionButtons.html("");
752 752 $actionButtons.html(closeAction);
753 753 };
754 754
755 755 closePullRequest = function (status) {
756 756 // inject closing flag
757 757 $('.action-buttons-extra').append('<input type="hidden" class="close-pr-input" id="close_pull_request" value="1">');
758 758 $(generalCommentForm.statusChange).select2("val", status).trigger('change');
759 759 $(generalCommentForm.submitForm).submit();
760 760 };
761 761
762 762 $('#show-outdated-comments').on('click', function(e){
763 763 var button = $(this);
764 764 var outdated = $('.comment-outdated');
765 765
766 766 if (button.html() === "(Show)") {
767 767 button.html("(Hide)");
768 768 outdated.show();
769 769 } else {
770 770 button.html("(Show)");
771 771 outdated.hide();
772 772 }
773 773 });
774 774
775 775 $('.show-inline-comments').on('change', function(e){
776 776 var show = 'none';
777 777 var target = e.currentTarget;
778 778 if(target.checked){
779 779 show = ''
780 780 }
781 781 var boxid = $(target).attr('id_for');
782 782 var comments = $('#{0} .inline-comments'.format(boxid));
783 783 var fn_display = function(idx){
784 784 $(this).css('display', show);
785 785 };
786 786 $(comments).each(fn_display);
787 787 var btns = $('#{0} .inline-comments-button'.format(boxid));
788 788 $(btns).each(fn_display);
789 789 });
790 790
791 791 $('#merge_pull_request_form').submit(function() {
792 792 if (!$('#merge_pull_request').attr('disabled')) {
793 793 $('#merge_pull_request').attr('disabled', 'disabled');
794 794 }
795 795 return true;
796 796 });
797 797
798 798 $('#edit_pull_request').on('click', function(e){
799 799 var title = $('#pr-title-input').val();
800 800 var description = codeMirrorInstance.getValue();
801 801 editPullRequest(
802 802 "${c.repo_name}", "${c.pull_request.pull_request_id}",
803 803 title, description);
804 804 });
805 805
806 806 $('#update_pull_request').on('click', function(e){
807 807 $(this).attr('disabled', 'disabled');
808 808 $(this).addClass('disabled');
809 809 $(this).html(_gettext('Saving...'));
810 810 reviewersController.updateReviewers(
811 811 "${c.repo_name}", "${c.pull_request.pull_request_id}");
812 812 });
813 813
814 814 $('#update_commits').on('click', function(e){
815 815 var isDisabled = !$(e.currentTarget).attr('disabled');
816 816 $(e.currentTarget).attr('disabled', 'disabled');
817 817 $(e.currentTarget).addClass('disabled');
818 818 $(e.currentTarget).removeClass('btn-primary');
819 819 $(e.currentTarget).text(_gettext('Updating...'));
820 820 if(isDisabled){
821 821 updateCommits(
822 822 "${c.repo_name}", "${c.pull_request.pull_request_id}");
823 823 }
824 824 });
825 825 // fixing issue with caches on firefox
826 826 $('#update_commits').removeAttr("disabled");
827 827
828 $('#close_pull_request').on('click', function(e){
829 closePullRequest(
830 "${c.repo_name}", "${c.pull_request.pull_request_id}");
831 });
832
833 828 $('.show-inline-comments').on('click', function(e){
834 829 var boxid = $(this).attr('data-comment-id');
835 830 var button = $(this);
836 831
837 832 if(button.hasClass("comments-visible")) {
838 833 $('#{0} .inline-comments'.format(boxid)).each(function(index){
839 834 $(this).hide();
840 835 });
841 836 button.removeClass("comments-visible");
842 837 } else {
843 838 $('#{0} .inline-comments'.format(boxid)).each(function(index){
844 839 $(this).show();
845 840 });
846 841 button.addClass("comments-visible");
847 842 }
848 843 });
849 844
850 845 // register submit callback on commentForm form to track TODOs
851 846 window.commentFormGlobalSubmitSuccessCallback = function(){
852 847 refreshMergeChecks();
853 848 };
854 849 // initial injection
855 850 injectCloseAction();
856 851
857 852 ReviewerAutoComplete('#user');
858 853
859 854 })
860 855 </script>
861 856
862 857 </div>
863 858 </div>
864 859
865 860 </%def>
@@ -1,1088 +1,1095 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 from webob.exc import HTTPNotFound
24 24
25 25 import rhodecode
26 26 from rhodecode.lib.vcs.nodes import FileNode
27 27 from rhodecode.lib import helpers as h
28 28 from rhodecode.model.changeset_status import ChangesetStatusModel
29 29 from rhodecode.model.db import (
30 30 PullRequest, ChangesetStatus, UserLog, Notification)
31 31 from rhodecode.model.meta import Session
32 32 from rhodecode.model.pull_request import PullRequestModel
33 33 from rhodecode.model.user import UserModel
34 34 from rhodecode.tests import (
35 35 assert_session_flash, url, TEST_USER_ADMIN_LOGIN, TEST_USER_REGULAR_LOGIN)
36 36 from rhodecode.tests.utils import AssertResponse
37 37
38 38
39 39 @pytest.mark.usefixtures('app', 'autologin_user')
40 40 @pytest.mark.backends("git", "hg")
41 41 class TestPullrequestsController(object):
42 42
43 43 def test_index(self, backend):
44 44 self.app.get(url(
45 45 controller='pullrequests', action='index',
46 46 repo_name=backend.repo_name))
47 47
48 48 def test_option_menu_create_pull_request_exists(self, backend):
49 49 repo_name = backend.repo_name
50 50 response = self.app.get(h.route_path('repo_summary', repo_name=repo_name))
51 51
52 52 create_pr_link = '<a href="%s">Create Pull Request</a>' % url(
53 53 'pullrequest', repo_name=repo_name)
54 54 response.mustcontain(create_pr_link)
55 55
56 56 def test_create_pr_form_with_raw_commit_id(self, backend):
57 57 repo = backend.repo
58 58
59 59 self.app.get(
60 60 url(controller='pullrequests', action='index',
61 61 repo_name=repo.repo_name,
62 62 commit=repo.get_commit().raw_id),
63 63 status=200)
64 64
65 65 @pytest.mark.parametrize('pr_merge_enabled', [True, False])
66 66 def test_show(self, pr_util, pr_merge_enabled):
67 67 pull_request = pr_util.create_pull_request(
68 68 mergeable=pr_merge_enabled, enable_notifications=False)
69 69
70 70 response = self.app.get(url(
71 71 controller='pullrequests', action='show',
72 72 repo_name=pull_request.target_repo.scm_instance().name,
73 73 pull_request_id=str(pull_request.pull_request_id)))
74 74
75 75 for commit_id in pull_request.revisions:
76 76 response.mustcontain(commit_id)
77 77
78 78 assert pull_request.target_ref_parts.type in response
79 79 assert pull_request.target_ref_parts.name in response
80 80 target_clone_url = pull_request.target_repo.clone_url()
81 81 assert target_clone_url in response
82 82
83 83 assert 'class="pull-request-merge"' in response
84 84 assert (
85 85 'Server-side pull request merging is disabled.'
86 86 in response) != pr_merge_enabled
87 87
88 88 def test_close_status_visibility(self, pr_util, user_util, csrf_token):
89 89 from rhodecode.tests.functional.test_login import login_url, logut_url
90 90 # Logout
91 91 response = self.app.post(
92 92 logut_url,
93 93 params={'csrf_token': csrf_token})
94 94 # Login as regular user
95 95 response = self.app.post(login_url,
96 96 {'username': TEST_USER_REGULAR_LOGIN,
97 97 'password': 'test12'})
98 98
99 99 pull_request = pr_util.create_pull_request(
100 100 author=TEST_USER_REGULAR_LOGIN)
101 101
102 102 response = self.app.get(url(
103 103 controller='pullrequests', action='show',
104 104 repo_name=pull_request.target_repo.scm_instance().name,
105 105 pull_request_id=str(pull_request.pull_request_id)))
106 106
107 107 response.mustcontain('Server-side pull request merging is disabled.')
108 108
109 109 assert_response = response.assert_response()
110 110 # for regular user without a merge permissions, we don't see it
111 111 assert_response.no_element_exists('#close-pull-request-action')
112 112
113 113 user_util.grant_user_permission_to_repo(
114 114 pull_request.target_repo,
115 115 UserModel().get_by_username(TEST_USER_REGULAR_LOGIN),
116 116 'repository.write')
117 117 response = self.app.get(url(
118 118 controller='pullrequests', action='show',
119 119 repo_name=pull_request.target_repo.scm_instance().name,
120 120 pull_request_id=str(pull_request.pull_request_id)))
121 121
122 122 response.mustcontain('Server-side pull request merging is disabled.')
123 123
124 124 assert_response = response.assert_response()
125 125 # now regular user has a merge permissions, we have CLOSE button
126 126 assert_response.one_element_exists('#close-pull-request-action')
127 127
128 128 def test_show_invalid_commit_id(self, pr_util):
129 129 # Simulating invalid revisions which will cause a lookup error
130 130 pull_request = pr_util.create_pull_request()
131 131 pull_request.revisions = ['invalid']
132 132 Session().add(pull_request)
133 133 Session().commit()
134 134
135 135 response = self.app.get(url(
136 136 controller='pullrequests', action='show',
137 137 repo_name=pull_request.target_repo.scm_instance().name,
138 138 pull_request_id=str(pull_request.pull_request_id)))
139 139
140 140 for commit_id in pull_request.revisions:
141 141 response.mustcontain(commit_id)
142 142
143 143 def test_show_invalid_source_reference(self, pr_util):
144 144 pull_request = pr_util.create_pull_request()
145 145 pull_request.source_ref = 'branch:b:invalid'
146 146 Session().add(pull_request)
147 147 Session().commit()
148 148
149 149 self.app.get(url(
150 150 controller='pullrequests', action='show',
151 151 repo_name=pull_request.target_repo.scm_instance().name,
152 152 pull_request_id=str(pull_request.pull_request_id)))
153 153
154 154 def test_edit_title_description(self, pr_util, csrf_token):
155 155 pull_request = pr_util.create_pull_request()
156 156 pull_request_id = pull_request.pull_request_id
157 157
158 158 response = self.app.post(
159 159 url(controller='pullrequests', action='update',
160 160 repo_name=pull_request.target_repo.repo_name,
161 161 pull_request_id=str(pull_request_id)),
162 162 params={
163 163 'edit_pull_request': 'true',
164 164 '_method': 'put',
165 165 'title': 'New title',
166 166 'description': 'New description',
167 167 'csrf_token': csrf_token})
168 168
169 169 assert_session_flash(
170 170 response, u'Pull request title & description updated.',
171 171 category='success')
172 172
173 173 pull_request = PullRequest.get(pull_request_id)
174 174 assert pull_request.title == 'New title'
175 175 assert pull_request.description == 'New description'
176 176
177 177 def test_edit_title_description_closed(self, pr_util, csrf_token):
178 178 pull_request = pr_util.create_pull_request()
179 179 pull_request_id = pull_request.pull_request_id
180 180 pr_util.close()
181 181
182 182 response = self.app.post(
183 183 url(controller='pullrequests', action='update',
184 184 repo_name=pull_request.target_repo.repo_name,
185 185 pull_request_id=str(pull_request_id)),
186 186 params={
187 187 'edit_pull_request': 'true',
188 188 '_method': 'put',
189 189 'title': 'New title',
190 190 'description': 'New description',
191 191 'csrf_token': csrf_token})
192 192
193 193 assert_session_flash(
194 194 response, u'Cannot update closed pull requests.',
195 195 category='error')
196 196
197 197 def test_update_invalid_source_reference(self, pr_util, csrf_token):
198 198 from rhodecode.lib.vcs.backends.base import UpdateFailureReason
199 199
200 200 pull_request = pr_util.create_pull_request()
201 201 pull_request.source_ref = 'branch:invalid-branch:invalid-commit-id'
202 202 Session().add(pull_request)
203 203 Session().commit()
204 204
205 205 pull_request_id = pull_request.pull_request_id
206 206
207 207 response = self.app.post(
208 208 url(controller='pullrequests', action='update',
209 209 repo_name=pull_request.target_repo.repo_name,
210 210 pull_request_id=str(pull_request_id)),
211 211 params={'update_commits': 'true', '_method': 'put',
212 212 'csrf_token': csrf_token})
213 213
214 214 expected_msg = PullRequestModel.UPDATE_STATUS_MESSAGES[
215 215 UpdateFailureReason.MISSING_SOURCE_REF]
216 216 assert_session_flash(response, expected_msg, category='error')
217 217
218 218 def test_missing_target_reference(self, pr_util, csrf_token):
219 219 from rhodecode.lib.vcs.backends.base import MergeFailureReason
220 220 pull_request = pr_util.create_pull_request(
221 221 approved=True, mergeable=True)
222 222 pull_request.target_ref = 'branch:invalid-branch:invalid-commit-id'
223 223 Session().add(pull_request)
224 224 Session().commit()
225 225
226 226 pull_request_id = pull_request.pull_request_id
227 227 pull_request_url = url(
228 228 controller='pullrequests', action='show',
229 229 repo_name=pull_request.target_repo.repo_name,
230 230 pull_request_id=str(pull_request_id))
231 231
232 232 response = self.app.get(pull_request_url)
233 233
234 234 assertr = AssertResponse(response)
235 235 expected_msg = PullRequestModel.MERGE_STATUS_MESSAGES[
236 236 MergeFailureReason.MISSING_TARGET_REF]
237 237 assertr.element_contains(
238 238 'span[data-role="merge-message"]', str(expected_msg))
239 239
240 def test_comment_and_close_pull_request(self, pr_util, csrf_token):
240 def test_comment_and_close_pull_request_custom_message_approved(
241 self, pr_util, csrf_token, xhr_header):
242
241 243 pull_request = pr_util.create_pull_request(approved=True)
242 244 pull_request_id = pull_request.pull_request_id
243 245 author = pull_request.user_id
244 246 repo = pull_request.target_repo.repo_id
245 247
246 248 self.app.post(
247 249 url(controller='pullrequests',
248 250 action='comment',
249 251 repo_name=pull_request.target_repo.scm_instance().name,
250 252 pull_request_id=str(pull_request_id)),
251 253 params={
252 'changeset_status': ChangesetStatus.STATUS_APPROVED,
253 254 'close_pull_request': '1',
254 255 'text': 'Closing a PR',
255 256 'csrf_token': csrf_token},
256 status=302)
257 extra_environ=xhr_header,)
257 258
258 259 action = 'user_closed_pull_request:%d' % pull_request_id
259 260 journal = UserLog.query()\
260 261 .filter(UserLog.user_id == author)\
261 262 .filter(UserLog.repository_id == repo)\
262 263 .filter(UserLog.action == action)\
263 264 .all()
264 265 assert len(journal) == 1
265 266
266 267 pull_request = PullRequest.get(pull_request_id)
267 268 assert pull_request.is_closed()
268 269
269 270 # check only the latest status, not the review status
270 271 status = ChangesetStatusModel().get_status(
271 272 pull_request.source_repo, pull_request=pull_request)
272 273 assert status == ChangesetStatus.STATUS_APPROVED
273
274 def test_reject_and_close_pull_request(self, pr_util, csrf_token):
275 pull_request = pr_util.create_pull_request()
276 pull_request_id = pull_request.pull_request_id
277 response = self.app.post(
278 url(controller='pullrequests',
279 action='update',
280 repo_name=pull_request.target_repo.scm_instance().name,
281 pull_request_id=str(pull_request.pull_request_id)),
282 params={'close_pull_request': 'true', '_method': 'put',
283 'csrf_token': csrf_token})
274 assert pull_request.comments[-1].text == 'Closing a PR'
284 275
285 pull_request = PullRequest.get(pull_request_id)
286
287 assert response.json is True
288 assert pull_request.is_closed()
289
290 # check only the latest status, not the review status
291 status = ChangesetStatusModel().get_status(
292 pull_request.source_repo, pull_request=pull_request)
293 assert status == ChangesetStatus.STATUS_REJECTED
294
295 def test_comment_force_close_pull_request(self, pr_util, csrf_token):
276 def test_comment_force_close_pull_request_rejected(
277 self, pr_util, csrf_token, xhr_header):
296 278 pull_request = pr_util.create_pull_request()
297 279 pull_request_id = pull_request.pull_request_id
298 280 PullRequestModel().update_reviewers(
299 281 pull_request_id, [(1, ['reason'], False), (2, ['reason2'], False)])
300 282 author = pull_request.user_id
301 283 repo = pull_request.target_repo.repo_id
284
302 285 self.app.post(
303 286 url(controller='pullrequests',
304 287 action='comment',
305 288 repo_name=pull_request.target_repo.scm_instance().name,
306 289 pull_request_id=str(pull_request_id)),
307 290 params={
308 'changeset_status': 'rejected',
309 291 'close_pull_request': '1',
310 292 'csrf_token': csrf_token},
311 status=302)
293 extra_environ=xhr_header)
312 294
313 295 pull_request = PullRequest.get(pull_request_id)
314 296
315 297 action = 'user_closed_pull_request:%d' % pull_request_id
316 298 journal = UserLog.query().filter(
317 299 UserLog.user_id == author,
318 300 UserLog.repository_id == repo,
319 301 UserLog.action == action).all()
320 302 assert len(journal) == 1
321 303
322 304 # check only the latest status, not the review status
323 305 status = ChangesetStatusModel().get_status(
324 306 pull_request.source_repo, pull_request=pull_request)
325 307 assert status == ChangesetStatus.STATUS_REJECTED
326 308
309 def test_comment_and_close_pull_request(
310 self, pr_util, csrf_token, xhr_header):
311 pull_request = pr_util.create_pull_request()
312 pull_request_id = pull_request.pull_request_id
313
314 response = self.app.post(
315 url(controller='pullrequests',
316 action='comment',
317 repo_name=pull_request.target_repo.scm_instance().name,
318 pull_request_id=str(pull_request.pull_request_id)),
319 params={
320 'close_pull_request': 'true',
321 'csrf_token': csrf_token},
322 extra_environ=xhr_header)
323
324 assert response.json
325
326 pull_request = PullRequest.get(pull_request_id)
327 assert pull_request.is_closed()
328
329 # check only the latest status, not the review status
330 status = ChangesetStatusModel().get_status(
331 pull_request.source_repo, pull_request=pull_request)
332 assert status == ChangesetStatus.STATUS_REJECTED
333
327 334 def test_create_pull_request(self, backend, csrf_token):
328 335 commits = [
329 336 {'message': 'ancestor'},
330 337 {'message': 'change'},
331 338 {'message': 'change2'},
332 339 ]
333 340 commit_ids = backend.create_master_repo(commits)
334 341 target = backend.create_repo(heads=['ancestor'])
335 342 source = backend.create_repo(heads=['change2'])
336 343
337 344 response = self.app.post(
338 345 url(
339 346 controller='pullrequests',
340 347 action='create',
341 348 repo_name=source.repo_name
342 349 ),
343 350 [
344 351 ('source_repo', source.repo_name),
345 352 ('source_ref', 'branch:default:' + commit_ids['change2']),
346 353 ('target_repo', target.repo_name),
347 354 ('target_ref', 'branch:default:' + commit_ids['ancestor']),
348 355 ('common_ancestor', commit_ids['ancestor']),
349 356 ('pullrequest_desc', 'Description'),
350 357 ('pullrequest_title', 'Title'),
351 358 ('__start__', 'review_members:sequence'),
352 359 ('__start__', 'reviewer:mapping'),
353 360 ('user_id', '1'),
354 361 ('__start__', 'reasons:sequence'),
355 362 ('reason', 'Some reason'),
356 363 ('__end__', 'reasons:sequence'),
357 364 ('mandatory', 'False'),
358 365 ('__end__', 'reviewer:mapping'),
359 366 ('__end__', 'review_members:sequence'),
360 367 ('__start__', 'revisions:sequence'),
361 368 ('revisions', commit_ids['change']),
362 369 ('revisions', commit_ids['change2']),
363 370 ('__end__', 'revisions:sequence'),
364 371 ('user', ''),
365 372 ('csrf_token', csrf_token),
366 373 ],
367 374 status=302)
368 375
369 376 location = response.headers['Location']
370 377 pull_request_id = location.rsplit('/', 1)[1]
371 378 assert pull_request_id != 'new'
372 379 pull_request = PullRequest.get(int(pull_request_id))
373 380
374 381 # check that we have now both revisions
375 382 assert pull_request.revisions == [commit_ids['change2'], commit_ids['change']]
376 383 assert pull_request.source_ref == 'branch:default:' + commit_ids['change2']
377 384 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
378 385 assert pull_request.target_ref == expected_target_ref
379 386
380 387 def test_reviewer_notifications(self, backend, csrf_token):
381 388 # We have to use the app.post for this test so it will create the
382 389 # notifications properly with the new PR
383 390 commits = [
384 391 {'message': 'ancestor',
385 392 'added': [FileNode('file_A', content='content_of_ancestor')]},
386 393 {'message': 'change',
387 394 'added': [FileNode('file_a', content='content_of_change')]},
388 395 {'message': 'change-child'},
389 396 {'message': 'ancestor-child', 'parents': ['ancestor'],
390 397 'added': [
391 398 FileNode('file_B', content='content_of_ancestor_child')]},
392 399 {'message': 'ancestor-child-2'},
393 400 ]
394 401 commit_ids = backend.create_master_repo(commits)
395 402 target = backend.create_repo(heads=['ancestor-child'])
396 403 source = backend.create_repo(heads=['change'])
397 404
398 405 response = self.app.post(
399 406 url(
400 407 controller='pullrequests',
401 408 action='create',
402 409 repo_name=source.repo_name
403 410 ),
404 411 [
405 412 ('source_repo', source.repo_name),
406 413 ('source_ref', 'branch:default:' + commit_ids['change']),
407 414 ('target_repo', target.repo_name),
408 415 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
409 416 ('common_ancestor', commit_ids['ancestor']),
410 417 ('pullrequest_desc', 'Description'),
411 418 ('pullrequest_title', 'Title'),
412 419 ('__start__', 'review_members:sequence'),
413 420 ('__start__', 'reviewer:mapping'),
414 421 ('user_id', '2'),
415 422 ('__start__', 'reasons:sequence'),
416 423 ('reason', 'Some reason'),
417 424 ('__end__', 'reasons:sequence'),
418 425 ('mandatory', 'False'),
419 426 ('__end__', 'reviewer:mapping'),
420 427 ('__end__', 'review_members:sequence'),
421 428 ('__start__', 'revisions:sequence'),
422 429 ('revisions', commit_ids['change']),
423 430 ('__end__', 'revisions:sequence'),
424 431 ('user', ''),
425 432 ('csrf_token', csrf_token),
426 433 ],
427 434 status=302)
428 435
429 436 location = response.headers['Location']
430 437
431 438 pull_request_id = location.rsplit('/', 1)[1]
432 439 assert pull_request_id != 'new'
433 440 pull_request = PullRequest.get(int(pull_request_id))
434 441
435 442 # Check that a notification was made
436 443 notifications = Notification.query()\
437 444 .filter(Notification.created_by == pull_request.author.user_id,
438 445 Notification.type_ == Notification.TYPE_PULL_REQUEST,
439 446 Notification.subject.contains(
440 447 "wants you to review pull request #%s" % pull_request_id))
441 448 assert len(notifications.all()) == 1
442 449
443 450 # Change reviewers and check that a notification was made
444 451 PullRequestModel().update_reviewers(
445 452 pull_request.pull_request_id, [(1, [], False)])
446 453 assert len(notifications.all()) == 2
447 454
448 455 def test_create_pull_request_stores_ancestor_commit_id(self, backend,
449 456 csrf_token):
450 457 commits = [
451 458 {'message': 'ancestor',
452 459 'added': [FileNode('file_A', content='content_of_ancestor')]},
453 460 {'message': 'change',
454 461 'added': [FileNode('file_a', content='content_of_change')]},
455 462 {'message': 'change-child'},
456 463 {'message': 'ancestor-child', 'parents': ['ancestor'],
457 464 'added': [
458 465 FileNode('file_B', content='content_of_ancestor_child')]},
459 466 {'message': 'ancestor-child-2'},
460 467 ]
461 468 commit_ids = backend.create_master_repo(commits)
462 469 target = backend.create_repo(heads=['ancestor-child'])
463 470 source = backend.create_repo(heads=['change'])
464 471
465 472 response = self.app.post(
466 473 url(
467 474 controller='pullrequests',
468 475 action='create',
469 476 repo_name=source.repo_name
470 477 ),
471 478 [
472 479 ('source_repo', source.repo_name),
473 480 ('source_ref', 'branch:default:' + commit_ids['change']),
474 481 ('target_repo', target.repo_name),
475 482 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
476 483 ('common_ancestor', commit_ids['ancestor']),
477 484 ('pullrequest_desc', 'Description'),
478 485 ('pullrequest_title', 'Title'),
479 486 ('__start__', 'review_members:sequence'),
480 487 ('__start__', 'reviewer:mapping'),
481 488 ('user_id', '1'),
482 489 ('__start__', 'reasons:sequence'),
483 490 ('reason', 'Some reason'),
484 491 ('__end__', 'reasons:sequence'),
485 492 ('mandatory', 'False'),
486 493 ('__end__', 'reviewer:mapping'),
487 494 ('__end__', 'review_members:sequence'),
488 495 ('__start__', 'revisions:sequence'),
489 496 ('revisions', commit_ids['change']),
490 497 ('__end__', 'revisions:sequence'),
491 498 ('user', ''),
492 499 ('csrf_token', csrf_token),
493 500 ],
494 501 status=302)
495 502
496 503 location = response.headers['Location']
497 504
498 505 pull_request_id = location.rsplit('/', 1)[1]
499 506 assert pull_request_id != 'new'
500 507 pull_request = PullRequest.get(int(pull_request_id))
501 508
502 509 # target_ref has to point to the ancestor's commit_id in order to
503 510 # show the correct diff
504 511 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
505 512 assert pull_request.target_ref == expected_target_ref
506 513
507 514 # Check generated diff contents
508 515 response = response.follow()
509 516 assert 'content_of_ancestor' not in response.body
510 517 assert 'content_of_ancestor-child' not in response.body
511 518 assert 'content_of_change' in response.body
512 519
513 520 def test_merge_pull_request_enabled(self, pr_util, csrf_token):
514 521 # Clear any previous calls to rcextensions
515 522 rhodecode.EXTENSIONS.calls.clear()
516 523
517 524 pull_request = pr_util.create_pull_request(
518 525 approved=True, mergeable=True)
519 526 pull_request_id = pull_request.pull_request_id
520 527 repo_name = pull_request.target_repo.scm_instance().name,
521 528
522 529 response = self.app.post(
523 530 url(controller='pullrequests',
524 531 action='merge',
525 532 repo_name=str(repo_name[0]),
526 533 pull_request_id=str(pull_request_id)),
527 534 params={'csrf_token': csrf_token}).follow()
528 535
529 536 pull_request = PullRequest.get(pull_request_id)
530 537
531 538 assert response.status_int == 200
532 539 assert pull_request.is_closed()
533 540 assert_pull_request_status(
534 541 pull_request, ChangesetStatus.STATUS_APPROVED)
535 542
536 543 # Check the relevant log entries were added
537 544 user_logs = UserLog.query() \
538 545 .filter(UserLog.version == UserLog.VERSION_1) \
539 546 .order_by('-user_log_id').limit(3)
540 547 actions = [log.action for log in user_logs]
541 548 pr_commit_ids = PullRequestModel()._get_commit_ids(pull_request)
542 549 expected_actions = [
543 550 u'user_closed_pull_request:%d' % pull_request_id,
544 551 u'user_merged_pull_request:%d' % pull_request_id,
545 552 # The action below reflect that the post push actions were executed
546 553 u'user_commented_pull_request:%d' % pull_request_id,
547 554 ]
548 555 assert actions == expected_actions
549 556
550 557 user_logs = UserLog.query() \
551 558 .filter(UserLog.version == UserLog.VERSION_2) \
552 559 .order_by('-user_log_id').limit(1)
553 560 actions = [log.action for log in user_logs]
554 561 assert actions == ['user.push']
555 562 assert user_logs[0].action_data['commit_ids'] == pr_commit_ids
556 563
557 564 # Check post_push rcextension was really executed
558 565 push_calls = rhodecode.EXTENSIONS.calls['post_push']
559 566 assert len(push_calls) == 1
560 567 unused_last_call_args, last_call_kwargs = push_calls[0]
561 568 assert last_call_kwargs['action'] == 'push'
562 569 assert last_call_kwargs['pushed_revs'] == pr_commit_ids
563 570
564 571 def test_merge_pull_request_disabled(self, pr_util, csrf_token):
565 572 pull_request = pr_util.create_pull_request(mergeable=False)
566 573 pull_request_id = pull_request.pull_request_id
567 574 pull_request = PullRequest.get(pull_request_id)
568 575
569 576 response = self.app.post(
570 577 url(controller='pullrequests',
571 578 action='merge',
572 579 repo_name=pull_request.target_repo.scm_instance().name,
573 580 pull_request_id=str(pull_request.pull_request_id)),
574 581 params={'csrf_token': csrf_token}).follow()
575 582
576 583 assert response.status_int == 200
577 584 response.mustcontain(
578 585 'Merge is not currently possible because of below failed checks.')
579 586 response.mustcontain('Server-side pull request merging is disabled.')
580 587
581 588 @pytest.mark.skip_backends('svn')
582 589 def test_merge_pull_request_not_approved(self, pr_util, csrf_token):
583 590 pull_request = pr_util.create_pull_request(mergeable=True)
584 591 pull_request_id = pull_request.pull_request_id
585 592 repo_name = pull_request.target_repo.scm_instance().name,
586 593
587 594 response = self.app.post(
588 595 url(controller='pullrequests',
589 596 action='merge',
590 597 repo_name=str(repo_name[0]),
591 598 pull_request_id=str(pull_request_id)),
592 599 params={'csrf_token': csrf_token}).follow()
593 600
594 601 assert response.status_int == 200
595 602
596 603 response.mustcontain(
597 604 'Merge is not currently possible because of below failed checks.')
598 605 response.mustcontain('Pull request reviewer approval is pending.')
599 606
600 607 def test_update_source_revision(self, backend, csrf_token):
601 608 commits = [
602 609 {'message': 'ancestor'},
603 610 {'message': 'change'},
604 611 {'message': 'change-2'},
605 612 ]
606 613 commit_ids = backend.create_master_repo(commits)
607 614 target = backend.create_repo(heads=['ancestor'])
608 615 source = backend.create_repo(heads=['change'])
609 616
610 617 # create pr from a in source to A in target
611 618 pull_request = PullRequest()
612 619 pull_request.source_repo = source
613 620 # TODO: johbo: Make sure that we write the source ref this way!
614 621 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
615 622 branch=backend.default_branch_name, commit_id=commit_ids['change'])
616 623 pull_request.target_repo = target
617 624
618 625 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
619 626 branch=backend.default_branch_name,
620 627 commit_id=commit_ids['ancestor'])
621 628 pull_request.revisions = [commit_ids['change']]
622 629 pull_request.title = u"Test"
623 630 pull_request.description = u"Description"
624 631 pull_request.author = UserModel().get_by_username(
625 632 TEST_USER_ADMIN_LOGIN)
626 633 Session().add(pull_request)
627 634 Session().commit()
628 635 pull_request_id = pull_request.pull_request_id
629 636
630 637 # source has ancestor - change - change-2
631 638 backend.pull_heads(source, heads=['change-2'])
632 639
633 640 # update PR
634 641 self.app.post(
635 642 url(controller='pullrequests', action='update',
636 643 repo_name=target.repo_name,
637 644 pull_request_id=str(pull_request_id)),
638 645 params={'update_commits': 'true', '_method': 'put',
639 646 'csrf_token': csrf_token})
640 647
641 648 # check that we have now both revisions
642 649 pull_request = PullRequest.get(pull_request_id)
643 650 assert pull_request.revisions == [
644 651 commit_ids['change-2'], commit_ids['change']]
645 652
646 653 # TODO: johbo: this should be a test on its own
647 654 response = self.app.get(url(
648 655 controller='pullrequests', action='index',
649 656 repo_name=target.repo_name))
650 657 assert response.status_int == 200
651 658 assert 'Pull request updated to' in response.body
652 659 assert 'with 1 added, 0 removed commits.' in response.body
653 660
654 661 def test_update_target_revision(self, backend, csrf_token):
655 662 commits = [
656 663 {'message': 'ancestor'},
657 664 {'message': 'change'},
658 665 {'message': 'ancestor-new', 'parents': ['ancestor']},
659 666 {'message': 'change-rebased'},
660 667 ]
661 668 commit_ids = backend.create_master_repo(commits)
662 669 target = backend.create_repo(heads=['ancestor'])
663 670 source = backend.create_repo(heads=['change'])
664 671
665 672 # create pr from a in source to A in target
666 673 pull_request = PullRequest()
667 674 pull_request.source_repo = source
668 675 # TODO: johbo: Make sure that we write the source ref this way!
669 676 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
670 677 branch=backend.default_branch_name, commit_id=commit_ids['change'])
671 678 pull_request.target_repo = target
672 679 # TODO: johbo: Target ref should be branch based, since tip can jump
673 680 # from branch to branch
674 681 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
675 682 branch=backend.default_branch_name,
676 683 commit_id=commit_ids['ancestor'])
677 684 pull_request.revisions = [commit_ids['change']]
678 685 pull_request.title = u"Test"
679 686 pull_request.description = u"Description"
680 687 pull_request.author = UserModel().get_by_username(
681 688 TEST_USER_ADMIN_LOGIN)
682 689 Session().add(pull_request)
683 690 Session().commit()
684 691 pull_request_id = pull_request.pull_request_id
685 692
686 693 # target has ancestor - ancestor-new
687 694 # source has ancestor - ancestor-new - change-rebased
688 695 backend.pull_heads(target, heads=['ancestor-new'])
689 696 backend.pull_heads(source, heads=['change-rebased'])
690 697
691 698 # update PR
692 699 self.app.post(
693 700 url(controller='pullrequests', action='update',
694 701 repo_name=target.repo_name,
695 702 pull_request_id=str(pull_request_id)),
696 703 params={'update_commits': 'true', '_method': 'put',
697 704 'csrf_token': csrf_token},
698 705 status=200)
699 706
700 707 # check that we have now both revisions
701 708 pull_request = PullRequest.get(pull_request_id)
702 709 assert pull_request.revisions == [commit_ids['change-rebased']]
703 710 assert pull_request.target_ref == 'branch:{branch}:{commit_id}'.format(
704 711 branch=backend.default_branch_name,
705 712 commit_id=commit_ids['ancestor-new'])
706 713
707 714 # TODO: johbo: This should be a test on its own
708 715 response = self.app.get(url(
709 716 controller='pullrequests', action='index',
710 717 repo_name=target.repo_name))
711 718 assert response.status_int == 200
712 719 assert 'Pull request updated to' in response.body
713 720 assert 'with 1 added, 1 removed commits.' in response.body
714 721
715 722 def test_update_of_ancestor_reference(self, backend, csrf_token):
716 723 commits = [
717 724 {'message': 'ancestor'},
718 725 {'message': 'change'},
719 726 {'message': 'change-2'},
720 727 {'message': 'ancestor-new', 'parents': ['ancestor']},
721 728 {'message': 'change-rebased'},
722 729 ]
723 730 commit_ids = backend.create_master_repo(commits)
724 731 target = backend.create_repo(heads=['ancestor'])
725 732 source = backend.create_repo(heads=['change'])
726 733
727 734 # create pr from a in source to A in target
728 735 pull_request = PullRequest()
729 736 pull_request.source_repo = source
730 737 # TODO: johbo: Make sure that we write the source ref this way!
731 738 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
732 739 branch=backend.default_branch_name,
733 740 commit_id=commit_ids['change'])
734 741 pull_request.target_repo = target
735 742 # TODO: johbo: Target ref should be branch based, since tip can jump
736 743 # from branch to branch
737 744 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
738 745 branch=backend.default_branch_name,
739 746 commit_id=commit_ids['ancestor'])
740 747 pull_request.revisions = [commit_ids['change']]
741 748 pull_request.title = u"Test"
742 749 pull_request.description = u"Description"
743 750 pull_request.author = UserModel().get_by_username(
744 751 TEST_USER_ADMIN_LOGIN)
745 752 Session().add(pull_request)
746 753 Session().commit()
747 754 pull_request_id = pull_request.pull_request_id
748 755
749 756 # target has ancestor - ancestor-new
750 757 # source has ancestor - ancestor-new - change-rebased
751 758 backend.pull_heads(target, heads=['ancestor-new'])
752 759 backend.pull_heads(source, heads=['change-rebased'])
753 760
754 761 # update PR
755 762 self.app.post(
756 763 url(controller='pullrequests', action='update',
757 764 repo_name=target.repo_name,
758 765 pull_request_id=str(pull_request_id)),
759 766 params={'update_commits': 'true', '_method': 'put',
760 767 'csrf_token': csrf_token},
761 768 status=200)
762 769
763 770 # Expect the target reference to be updated correctly
764 771 pull_request = PullRequest.get(pull_request_id)
765 772 assert pull_request.revisions == [commit_ids['change-rebased']]
766 773 expected_target_ref = 'branch:{branch}:{commit_id}'.format(
767 774 branch=backend.default_branch_name,
768 775 commit_id=commit_ids['ancestor-new'])
769 776 assert pull_request.target_ref == expected_target_ref
770 777
771 778 def test_remove_pull_request_branch(self, backend_git, csrf_token):
772 779 branch_name = 'development'
773 780 commits = [
774 781 {'message': 'initial-commit'},
775 782 {'message': 'old-feature'},
776 783 {'message': 'new-feature', 'branch': branch_name},
777 784 ]
778 785 repo = backend_git.create_repo(commits)
779 786 commit_ids = backend_git.commit_ids
780 787
781 788 pull_request = PullRequest()
782 789 pull_request.source_repo = repo
783 790 pull_request.target_repo = repo
784 791 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
785 792 branch=branch_name, commit_id=commit_ids['new-feature'])
786 793 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
787 794 branch=backend_git.default_branch_name,
788 795 commit_id=commit_ids['old-feature'])
789 796 pull_request.revisions = [commit_ids['new-feature']]
790 797 pull_request.title = u"Test"
791 798 pull_request.description = u"Description"
792 799 pull_request.author = UserModel().get_by_username(
793 800 TEST_USER_ADMIN_LOGIN)
794 801 Session().add(pull_request)
795 802 Session().commit()
796 803
797 804 vcs = repo.scm_instance()
798 805 vcs.remove_ref('refs/heads/{}'.format(branch_name))
799 806
800 807 response = self.app.get(url(
801 808 controller='pullrequests', action='show',
802 809 repo_name=repo.repo_name,
803 810 pull_request_id=str(pull_request.pull_request_id)))
804 811
805 812 assert response.status_int == 200
806 813 assert_response = AssertResponse(response)
807 814 assert_response.element_contains(
808 815 '#changeset_compare_view_content .alert strong',
809 816 'Missing commits')
810 817 assert_response.element_contains(
811 818 '#changeset_compare_view_content .alert',
812 819 'This pull request cannot be displayed, because one or more'
813 820 ' commits no longer exist in the source repository.')
814 821
815 822 def test_strip_commits_from_pull_request(
816 823 self, backend, pr_util, csrf_token):
817 824 commits = [
818 825 {'message': 'initial-commit'},
819 826 {'message': 'old-feature'},
820 827 {'message': 'new-feature', 'parents': ['initial-commit']},
821 828 ]
822 829 pull_request = pr_util.create_pull_request(
823 830 commits, target_head='initial-commit', source_head='new-feature',
824 831 revisions=['new-feature'])
825 832
826 833 vcs = pr_util.source_repository.scm_instance()
827 834 if backend.alias == 'git':
828 835 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
829 836 else:
830 837 vcs.strip(pr_util.commit_ids['new-feature'])
831 838
832 839 response = self.app.get(url(
833 840 controller='pullrequests', action='show',
834 841 repo_name=pr_util.target_repository.repo_name,
835 842 pull_request_id=str(pull_request.pull_request_id)))
836 843
837 844 assert response.status_int == 200
838 845 assert_response = AssertResponse(response)
839 846 assert_response.element_contains(
840 847 '#changeset_compare_view_content .alert strong',
841 848 'Missing commits')
842 849 assert_response.element_contains(
843 850 '#changeset_compare_view_content .alert',
844 851 'This pull request cannot be displayed, because one or more'
845 852 ' commits no longer exist in the source repository.')
846 853 assert_response.element_contains(
847 854 '#update_commits',
848 855 'Update commits')
849 856
850 857 def test_strip_commits_and_update(
851 858 self, backend, pr_util, csrf_token):
852 859 commits = [
853 860 {'message': 'initial-commit'},
854 861 {'message': 'old-feature'},
855 862 {'message': 'new-feature', 'parents': ['old-feature']},
856 863 ]
857 864 pull_request = pr_util.create_pull_request(
858 865 commits, target_head='old-feature', source_head='new-feature',
859 866 revisions=['new-feature'], mergeable=True)
860 867
861 868 vcs = pr_util.source_repository.scm_instance()
862 869 if backend.alias == 'git':
863 870 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
864 871 else:
865 872 vcs.strip(pr_util.commit_ids['new-feature'])
866 873
867 874 response = self.app.post(
868 875 url(controller='pullrequests', action='update',
869 876 repo_name=pull_request.target_repo.repo_name,
870 877 pull_request_id=str(pull_request.pull_request_id)),
871 878 params={'update_commits': 'true', '_method': 'put',
872 879 'csrf_token': csrf_token})
873 880
874 881 assert response.status_int == 200
875 882 assert response.body == 'true'
876 883
877 884 # Make sure that after update, it won't raise 500 errors
878 885 response = self.app.get(url(
879 886 controller='pullrequests', action='show',
880 887 repo_name=pr_util.target_repository.repo_name,
881 888 pull_request_id=str(pull_request.pull_request_id)))
882 889
883 890 assert response.status_int == 200
884 891 assert_response = AssertResponse(response)
885 892 assert_response.element_contains(
886 893 '#changeset_compare_view_content .alert strong',
887 894 'Missing commits')
888 895
889 896 def test_branch_is_a_link(self, pr_util):
890 897 pull_request = pr_util.create_pull_request()
891 898 pull_request.source_ref = 'branch:origin:1234567890abcdef'
892 899 pull_request.target_ref = 'branch:target:abcdef1234567890'
893 900 Session().add(pull_request)
894 901 Session().commit()
895 902
896 903 response = self.app.get(url(
897 904 controller='pullrequests', action='show',
898 905 repo_name=pull_request.target_repo.scm_instance().name,
899 906 pull_request_id=str(pull_request.pull_request_id)))
900 907 assert response.status_int == 200
901 908 assert_response = AssertResponse(response)
902 909
903 910 origin = assert_response.get_element('.pr-origininfo .tag')
904 911 origin_children = origin.getchildren()
905 912 assert len(origin_children) == 1
906 913 target = assert_response.get_element('.pr-targetinfo .tag')
907 914 target_children = target.getchildren()
908 915 assert len(target_children) == 1
909 916
910 917 expected_origin_link = url(
911 918 'changelog_home',
912 919 repo_name=pull_request.source_repo.scm_instance().name,
913 920 branch='origin')
914 921 expected_target_link = url(
915 922 'changelog_home',
916 923 repo_name=pull_request.target_repo.scm_instance().name,
917 924 branch='target')
918 925 assert origin_children[0].attrib['href'] == expected_origin_link
919 926 assert origin_children[0].text == 'branch: origin'
920 927 assert target_children[0].attrib['href'] == expected_target_link
921 928 assert target_children[0].text == 'branch: target'
922 929
923 930 def test_bookmark_is_not_a_link(self, pr_util):
924 931 pull_request = pr_util.create_pull_request()
925 932 pull_request.source_ref = 'bookmark:origin:1234567890abcdef'
926 933 pull_request.target_ref = 'bookmark:target:abcdef1234567890'
927 934 Session().add(pull_request)
928 935 Session().commit()
929 936
930 937 response = self.app.get(url(
931 938 controller='pullrequests', action='show',
932 939 repo_name=pull_request.target_repo.scm_instance().name,
933 940 pull_request_id=str(pull_request.pull_request_id)))
934 941 assert response.status_int == 200
935 942 assert_response = AssertResponse(response)
936 943
937 944 origin = assert_response.get_element('.pr-origininfo .tag')
938 945 assert origin.text.strip() == 'bookmark: origin'
939 946 assert origin.getchildren() == []
940 947
941 948 target = assert_response.get_element('.pr-targetinfo .tag')
942 949 assert target.text.strip() == 'bookmark: target'
943 950 assert target.getchildren() == []
944 951
945 952 def test_tag_is_not_a_link(self, pr_util):
946 953 pull_request = pr_util.create_pull_request()
947 954 pull_request.source_ref = 'tag:origin:1234567890abcdef'
948 955 pull_request.target_ref = 'tag:target:abcdef1234567890'
949 956 Session().add(pull_request)
950 957 Session().commit()
951 958
952 959 response = self.app.get(url(
953 960 controller='pullrequests', action='show',
954 961 repo_name=pull_request.target_repo.scm_instance().name,
955 962 pull_request_id=str(pull_request.pull_request_id)))
956 963 assert response.status_int == 200
957 964 assert_response = AssertResponse(response)
958 965
959 966 origin = assert_response.get_element('.pr-origininfo .tag')
960 967 assert origin.text.strip() == 'tag: origin'
961 968 assert origin.getchildren() == []
962 969
963 970 target = assert_response.get_element('.pr-targetinfo .tag')
964 971 assert target.text.strip() == 'tag: target'
965 972 assert target.getchildren() == []
966 973
967 974 @pytest.mark.parametrize('mergeable', [True, False])
968 975 def test_shadow_repository_link(
969 976 self, mergeable, pr_util, http_host_only_stub):
970 977 """
971 978 Check that the pull request summary page displays a link to the shadow
972 979 repository if the pull request is mergeable. If it is not mergeable
973 980 the link should not be displayed.
974 981 """
975 982 pull_request = pr_util.create_pull_request(
976 983 mergeable=mergeable, enable_notifications=False)
977 984 target_repo = pull_request.target_repo.scm_instance()
978 985 pr_id = pull_request.pull_request_id
979 986 shadow_url = '{host}/{repo}/pull-request/{pr_id}/repository'.format(
980 987 host=http_host_only_stub, repo=target_repo.name, pr_id=pr_id)
981 988
982 989 response = self.app.get(url(
983 990 controller='pullrequests', action='show',
984 991 repo_name=target_repo.name,
985 992 pull_request_id=str(pr_id)))
986 993
987 994 assertr = AssertResponse(response)
988 995 if mergeable:
989 996 assertr.element_value_contains(
990 997 'div.pr-mergeinfo input', shadow_url)
991 998 assertr.element_value_contains(
992 999 'div.pr-mergeinfo input', 'pr-merge')
993 1000 else:
994 1001 assertr.no_element_exists('div.pr-mergeinfo')
995 1002
996 1003
997 1004 @pytest.mark.usefixtures('app')
998 1005 @pytest.mark.backends("git", "hg")
999 1006 class TestPullrequestsControllerDelete(object):
1000 1007 def test_pull_request_delete_button_permissions_admin(
1001 1008 self, autologin_user, user_admin, pr_util):
1002 1009 pull_request = pr_util.create_pull_request(
1003 1010 author=user_admin.username, enable_notifications=False)
1004 1011
1005 1012 response = self.app.get(url(
1006 1013 controller='pullrequests', action='show',
1007 1014 repo_name=pull_request.target_repo.scm_instance().name,
1008 1015 pull_request_id=str(pull_request.pull_request_id)))
1009 1016
1010 1017 response.mustcontain('id="delete_pullrequest"')
1011 1018 response.mustcontain('Confirm to delete this pull request')
1012 1019
1013 1020 def test_pull_request_delete_button_permissions_owner(
1014 1021 self, autologin_regular_user, user_regular, pr_util):
1015 1022 pull_request = pr_util.create_pull_request(
1016 1023 author=user_regular.username, enable_notifications=False)
1017 1024
1018 1025 response = self.app.get(url(
1019 1026 controller='pullrequests', action='show',
1020 1027 repo_name=pull_request.target_repo.scm_instance().name,
1021 1028 pull_request_id=str(pull_request.pull_request_id)))
1022 1029
1023 1030 response.mustcontain('id="delete_pullrequest"')
1024 1031 response.mustcontain('Confirm to delete this pull request')
1025 1032
1026 1033 def test_pull_request_delete_button_permissions_forbidden(
1027 1034 self, autologin_regular_user, user_regular, user_admin, pr_util):
1028 1035 pull_request = pr_util.create_pull_request(
1029 1036 author=user_admin.username, enable_notifications=False)
1030 1037
1031 1038 response = self.app.get(url(
1032 1039 controller='pullrequests', action='show',
1033 1040 repo_name=pull_request.target_repo.scm_instance().name,
1034 1041 pull_request_id=str(pull_request.pull_request_id)))
1035 1042 response.mustcontain(no=['id="delete_pullrequest"'])
1036 1043 response.mustcontain(no=['Confirm to delete this pull request'])
1037 1044
1038 1045 def test_pull_request_delete_button_permissions_can_update_cannot_delete(
1039 1046 self, autologin_regular_user, user_regular, user_admin, pr_util,
1040 1047 user_util):
1041 1048
1042 1049 pull_request = pr_util.create_pull_request(
1043 1050 author=user_admin.username, enable_notifications=False)
1044 1051
1045 1052 user_util.grant_user_permission_to_repo(
1046 1053 pull_request.target_repo, user_regular,
1047 1054 'repository.write')
1048 1055
1049 1056 response = self.app.get(url(
1050 1057 controller='pullrequests', action='show',
1051 1058 repo_name=pull_request.target_repo.scm_instance().name,
1052 1059 pull_request_id=str(pull_request.pull_request_id)))
1053 1060
1054 1061 response.mustcontain('id="open_edit_pullrequest"')
1055 1062 response.mustcontain('id="delete_pullrequest"')
1056 1063 response.mustcontain(no=['Confirm to delete this pull request'])
1057 1064
1058 1065
1059 1066 def assert_pull_request_status(pull_request, expected_status):
1060 1067 status = ChangesetStatusModel().calculated_review_status(
1061 1068 pull_request=pull_request)
1062 1069 assert status == expected_status
1063 1070
1064 1071
1065 1072 @pytest.mark.parametrize('action', ['index', 'create'])
1066 1073 @pytest.mark.usefixtures("autologin_user")
1067 1074 def test_redirects_to_repo_summary_for_svn_repositories(backend_svn, app, action):
1068 1075 response = app.get(url(
1069 1076 controller='pullrequests', action=action,
1070 1077 repo_name=backend_svn.repo_name))
1071 1078 assert response.status_int == 302
1072 1079
1073 1080 # Not allowed, redirect to the summary
1074 1081 redirected = response.follow()
1075 1082 summary_url = h.route_path('repo_summary', repo_name=backend_svn.repo_name)
1076 1083
1077 1084 # URL adds leading slash and path doesn't have it
1078 1085 assert redirected.request.path == summary_url
1079 1086
1080 1087
1081 1088 def test_delete_comment_returns_404_if_comment_does_not_exist(pylonsapp):
1082 1089 # TODO: johbo: Global import not possible because models.forms blows up
1083 1090 from rhodecode.controllers.pullrequests import PullrequestsController
1084 1091 controller = PullrequestsController()
1085 1092 patcher = mock.patch(
1086 1093 'rhodecode.model.db.BaseModel.get', return_value=None)
1087 1094 with pytest.raises(HTTPNotFound), patcher:
1088 1095 controller._delete_comment(1)
General Comments 0
You need to be logged in to leave comments. Login now