##// END OF EJS Templates
pull-requests: moved force refresh to update commits button....
marcink -
r4101:f857226e default
parent child Browse files
Show More
@@ -1,1217 +1,1217 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20 import mock
21 21 import pytest
22 22
23 23 import rhodecode
24 24 from rhodecode.lib.vcs.backends.base import MergeResponse, MergeFailureReason
25 25 from rhodecode.lib.vcs.nodes import FileNode
26 26 from rhodecode.lib import helpers as h
27 27 from rhodecode.model.changeset_status import ChangesetStatusModel
28 28 from rhodecode.model.db import (
29 29 PullRequest, ChangesetStatus, UserLog, Notification, ChangesetComment, Repository)
30 30 from rhodecode.model.meta import Session
31 31 from rhodecode.model.pull_request import PullRequestModel
32 32 from rhodecode.model.user import UserModel
33 33 from rhodecode.tests import (
34 34 assert_session_flash, TEST_USER_ADMIN_LOGIN, TEST_USER_REGULAR_LOGIN)
35 35
36 36
37 37 def route_path(name, params=None, **kwargs):
38 38 import urllib
39 39
40 40 base_url = {
41 41 'repo_changelog': '/{repo_name}/changelog',
42 42 'repo_changelog_file': '/{repo_name}/changelog/{commit_id}/{f_path}',
43 43 'repo_commits': '/{repo_name}/commits',
44 44 'repo_commits_file': '/{repo_name}/commits/{commit_id}/{f_path}',
45 45 'pullrequest_show': '/{repo_name}/pull-request/{pull_request_id}',
46 46 'pullrequest_show_all': '/{repo_name}/pull-request',
47 47 'pullrequest_show_all_data': '/{repo_name}/pull-request-data',
48 48 'pullrequest_repo_refs': '/{repo_name}/pull-request/refs/{target_repo_name:.*?[^/]}',
49 49 'pullrequest_repo_targets': '/{repo_name}/pull-request/repo-destinations',
50 50 'pullrequest_new': '/{repo_name}/pull-request/new',
51 51 'pullrequest_create': '/{repo_name}/pull-request/create',
52 52 'pullrequest_update': '/{repo_name}/pull-request/{pull_request_id}/update',
53 53 'pullrequest_merge': '/{repo_name}/pull-request/{pull_request_id}/merge',
54 54 'pullrequest_delete': '/{repo_name}/pull-request/{pull_request_id}/delete',
55 55 'pullrequest_comment_create': '/{repo_name}/pull-request/{pull_request_id}/comment',
56 56 'pullrequest_comment_delete': '/{repo_name}/pull-request/{pull_request_id}/comment/{comment_id}/delete',
57 57 }[name].format(**kwargs)
58 58
59 59 if params:
60 60 base_url = '{}?{}'.format(base_url, urllib.urlencode(params))
61 61 return base_url
62 62
63 63
64 64 @pytest.mark.usefixtures('app', 'autologin_user')
65 65 @pytest.mark.backends("git", "hg")
66 66 class TestPullrequestsView(object):
67 67
68 68 def test_index(self, backend):
69 69 self.app.get(route_path(
70 70 'pullrequest_new',
71 71 repo_name=backend.repo_name))
72 72
73 73 def test_option_menu_create_pull_request_exists(self, backend):
74 74 repo_name = backend.repo_name
75 75 response = self.app.get(h.route_path('repo_summary', repo_name=repo_name))
76 76
77 77 create_pr_link = '<a href="%s">Create Pull Request</a>' % route_path(
78 78 'pullrequest_new', repo_name=repo_name)
79 79 response.mustcontain(create_pr_link)
80 80
81 81 def test_create_pr_form_with_raw_commit_id(self, backend):
82 82 repo = backend.repo
83 83
84 84 self.app.get(
85 85 route_path('pullrequest_new', repo_name=repo.repo_name,
86 86 commit=repo.get_commit().raw_id),
87 87 status=200)
88 88
89 89 @pytest.mark.parametrize('pr_merge_enabled', [True, False])
90 90 @pytest.mark.parametrize('range_diff', ["0", "1"])
91 91 def test_show(self, pr_util, pr_merge_enabled, range_diff):
92 92 pull_request = pr_util.create_pull_request(
93 93 mergeable=pr_merge_enabled, enable_notifications=False)
94 94
95 95 response = self.app.get(route_path(
96 96 'pullrequest_show',
97 97 repo_name=pull_request.target_repo.scm_instance().name,
98 98 pull_request_id=pull_request.pull_request_id,
99 99 params={'range-diff': range_diff}))
100 100
101 101 for commit_id in pull_request.revisions:
102 102 response.mustcontain(commit_id)
103 103
104 104 assert pull_request.target_ref_parts.type in response
105 105 assert pull_request.target_ref_parts.name in response
106 106 target_clone_url = pull_request.target_repo.clone_url()
107 107 assert target_clone_url in response
108 108
109 109 assert 'class="pull-request-merge"' in response
110 110 if pr_merge_enabled:
111 111 response.mustcontain('Pull request reviewer approval is pending')
112 112 else:
113 113 response.mustcontain('Server-side pull request merging is disabled.')
114 114
115 115 if range_diff == "1":
116 116 response.mustcontain('Turn off: Show the diff as commit range')
117 117
118 118 def test_close_status_visibility(self, pr_util, user_util, csrf_token):
119 119 # Logout
120 120 response = self.app.post(
121 121 h.route_path('logout'),
122 122 params={'csrf_token': csrf_token})
123 123 # Login as regular user
124 124 response = self.app.post(h.route_path('login'),
125 125 {'username': TEST_USER_REGULAR_LOGIN,
126 126 'password': 'test12'})
127 127
128 128 pull_request = pr_util.create_pull_request(
129 129 author=TEST_USER_REGULAR_LOGIN)
130 130
131 131 response = self.app.get(route_path(
132 132 'pullrequest_show',
133 133 repo_name=pull_request.target_repo.scm_instance().name,
134 134 pull_request_id=pull_request.pull_request_id))
135 135
136 136 response.mustcontain('Server-side pull request merging is disabled.')
137 137
138 138 assert_response = response.assert_response()
139 139 # for regular user without a merge permissions, we don't see it
140 140 assert_response.no_element_exists('#close-pull-request-action')
141 141
142 142 user_util.grant_user_permission_to_repo(
143 143 pull_request.target_repo,
144 144 UserModel().get_by_username(TEST_USER_REGULAR_LOGIN),
145 145 'repository.write')
146 146 response = self.app.get(route_path(
147 147 'pullrequest_show',
148 148 repo_name=pull_request.target_repo.scm_instance().name,
149 149 pull_request_id=pull_request.pull_request_id))
150 150
151 151 response.mustcontain('Server-side pull request merging is disabled.')
152 152
153 153 assert_response = response.assert_response()
154 154 # now regular user has a merge permissions, we have CLOSE button
155 155 assert_response.one_element_exists('#close-pull-request-action')
156 156
157 157 def test_show_invalid_commit_id(self, pr_util):
158 158 # Simulating invalid revisions which will cause a lookup error
159 159 pull_request = pr_util.create_pull_request()
160 160 pull_request.revisions = ['invalid']
161 161 Session().add(pull_request)
162 162 Session().commit()
163 163
164 164 response = self.app.get(route_path(
165 165 'pullrequest_show',
166 166 repo_name=pull_request.target_repo.scm_instance().name,
167 167 pull_request_id=pull_request.pull_request_id))
168 168
169 169 for commit_id in pull_request.revisions:
170 170 response.mustcontain(commit_id)
171 171
172 172 def test_show_invalid_source_reference(self, pr_util):
173 173 pull_request = pr_util.create_pull_request()
174 174 pull_request.source_ref = 'branch:b:invalid'
175 175 Session().add(pull_request)
176 176 Session().commit()
177 177
178 178 self.app.get(route_path(
179 179 'pullrequest_show',
180 180 repo_name=pull_request.target_repo.scm_instance().name,
181 181 pull_request_id=pull_request.pull_request_id))
182 182
183 183 def test_edit_title_description(self, pr_util, csrf_token):
184 184 pull_request = pr_util.create_pull_request()
185 185 pull_request_id = pull_request.pull_request_id
186 186
187 187 response = self.app.post(
188 188 route_path('pullrequest_update',
189 189 repo_name=pull_request.target_repo.repo_name,
190 190 pull_request_id=pull_request_id),
191 191 params={
192 192 'edit_pull_request': 'true',
193 193 'title': 'New title',
194 194 'description': 'New description',
195 195 'csrf_token': csrf_token})
196 196
197 197 assert_session_flash(
198 198 response, u'Pull request title & description updated.',
199 199 category='success')
200 200
201 201 pull_request = PullRequest.get(pull_request_id)
202 202 assert pull_request.title == 'New title'
203 203 assert pull_request.description == 'New description'
204 204
205 205 def test_edit_title_description_closed(self, pr_util, csrf_token):
206 206 pull_request = pr_util.create_pull_request()
207 207 pull_request_id = pull_request.pull_request_id
208 208 repo_name = pull_request.target_repo.repo_name
209 209 pr_util.close()
210 210
211 211 response = self.app.post(
212 212 route_path('pullrequest_update',
213 213 repo_name=repo_name, pull_request_id=pull_request_id),
214 214 params={
215 215 'edit_pull_request': 'true',
216 216 'title': 'New title',
217 217 'description': 'New description',
218 218 'csrf_token': csrf_token}, status=200)
219 219 assert_session_flash(
220 220 response, u'Cannot update closed pull requests.',
221 221 category='error')
222 222
223 223 def test_update_invalid_source_reference(self, pr_util, csrf_token):
224 224 from rhodecode.lib.vcs.backends.base import UpdateFailureReason
225 225
226 226 pull_request = pr_util.create_pull_request()
227 227 pull_request.source_ref = 'branch:invalid-branch:invalid-commit-id'
228 228 Session().add(pull_request)
229 229 Session().commit()
230 230
231 231 pull_request_id = pull_request.pull_request_id
232 232
233 233 response = self.app.post(
234 234 route_path('pullrequest_update',
235 235 repo_name=pull_request.target_repo.repo_name,
236 236 pull_request_id=pull_request_id),
237 237 params={'update_commits': 'true', 'csrf_token': csrf_token})
238 238
239 239 expected_msg = str(PullRequestModel.UPDATE_STATUS_MESSAGES[
240 240 UpdateFailureReason.MISSING_SOURCE_REF])
241 241 assert_session_flash(response, expected_msg, category='error')
242 242
243 243 def test_missing_target_reference(self, pr_util, csrf_token):
244 244 from rhodecode.lib.vcs.backends.base import MergeFailureReason
245 245 pull_request = pr_util.create_pull_request(
246 246 approved=True, mergeable=True)
247 247 unicode_reference = u'branch:invalid-branch:invalid-commit-id'
248 248 pull_request.target_ref = unicode_reference
249 249 Session().add(pull_request)
250 250 Session().commit()
251 251
252 252 pull_request_id = pull_request.pull_request_id
253 253 pull_request_url = route_path(
254 254 'pullrequest_show',
255 255 repo_name=pull_request.target_repo.repo_name,
256 256 pull_request_id=pull_request_id)
257 257
258 258 response = self.app.get(pull_request_url)
259 259 target_ref_id = 'invalid-branch'
260 260 merge_resp = MergeResponse(
261 261 True, True, '', MergeFailureReason.MISSING_TARGET_REF,
262 262 metadata={'target_ref': PullRequest.unicode_to_reference(unicode_reference)})
263 263 response.assert_response().element_contains(
264 264 'div[data-role="merge-message"]', merge_resp.merge_status_message)
265 265
266 266 def test_comment_and_close_pull_request_custom_message_approved(
267 267 self, pr_util, csrf_token, xhr_header):
268 268
269 269 pull_request = pr_util.create_pull_request(approved=True)
270 270 pull_request_id = pull_request.pull_request_id
271 271 author = pull_request.user_id
272 272 repo = pull_request.target_repo.repo_id
273 273
274 274 self.app.post(
275 275 route_path('pullrequest_comment_create',
276 276 repo_name=pull_request.target_repo.scm_instance().name,
277 277 pull_request_id=pull_request_id),
278 278 params={
279 279 'close_pull_request': '1',
280 280 'text': 'Closing a PR',
281 281 'csrf_token': csrf_token},
282 282 extra_environ=xhr_header,)
283 283
284 284 journal = UserLog.query()\
285 285 .filter(UserLog.user_id == author)\
286 286 .filter(UserLog.repository_id == repo) \
287 287 .order_by(UserLog.user_log_id.asc()) \
288 288 .all()
289 289 assert journal[-1].action == 'repo.pull_request.close'
290 290
291 291 pull_request = PullRequest.get(pull_request_id)
292 292 assert pull_request.is_closed()
293 293
294 294 status = ChangesetStatusModel().get_status(
295 295 pull_request.source_repo, pull_request=pull_request)
296 296 assert status == ChangesetStatus.STATUS_APPROVED
297 297 comments = ChangesetComment().query() \
298 298 .filter(ChangesetComment.pull_request == pull_request) \
299 299 .order_by(ChangesetComment.comment_id.asc())\
300 300 .all()
301 301 assert comments[-1].text == 'Closing a PR'
302 302
303 303 def test_comment_force_close_pull_request_rejected(
304 304 self, pr_util, csrf_token, xhr_header):
305 305 pull_request = pr_util.create_pull_request()
306 306 pull_request_id = pull_request.pull_request_id
307 307 PullRequestModel().update_reviewers(
308 308 pull_request_id, [(1, ['reason'], False, []), (2, ['reason2'], False, [])],
309 309 pull_request.author)
310 310 author = pull_request.user_id
311 311 repo = pull_request.target_repo.repo_id
312 312
313 313 self.app.post(
314 314 route_path('pullrequest_comment_create',
315 315 repo_name=pull_request.target_repo.scm_instance().name,
316 316 pull_request_id=pull_request_id),
317 317 params={
318 318 'close_pull_request': '1',
319 319 'csrf_token': csrf_token},
320 320 extra_environ=xhr_header)
321 321
322 322 pull_request = PullRequest.get(pull_request_id)
323 323
324 324 journal = UserLog.query()\
325 325 .filter(UserLog.user_id == author, UserLog.repository_id == repo) \
326 326 .order_by(UserLog.user_log_id.asc()) \
327 327 .all()
328 328 assert journal[-1].action == 'repo.pull_request.close'
329 329
330 330 # check only the latest status, not the review status
331 331 status = ChangesetStatusModel().get_status(
332 332 pull_request.source_repo, pull_request=pull_request)
333 333 assert status == ChangesetStatus.STATUS_REJECTED
334 334
335 335 def test_comment_and_close_pull_request(
336 336 self, pr_util, csrf_token, xhr_header):
337 337 pull_request = pr_util.create_pull_request()
338 338 pull_request_id = pull_request.pull_request_id
339 339
340 340 response = self.app.post(
341 341 route_path('pullrequest_comment_create',
342 342 repo_name=pull_request.target_repo.scm_instance().name,
343 343 pull_request_id=pull_request.pull_request_id),
344 344 params={
345 345 'close_pull_request': 'true',
346 346 'csrf_token': csrf_token},
347 347 extra_environ=xhr_header)
348 348
349 349 assert response.json
350 350
351 351 pull_request = PullRequest.get(pull_request_id)
352 352 assert pull_request.is_closed()
353 353
354 354 # check only the latest status, not the review status
355 355 status = ChangesetStatusModel().get_status(
356 356 pull_request.source_repo, pull_request=pull_request)
357 357 assert status == ChangesetStatus.STATUS_REJECTED
358 358
359 359 def test_create_pull_request(self, backend, csrf_token):
360 360 commits = [
361 361 {'message': 'ancestor'},
362 362 {'message': 'change'},
363 363 {'message': 'change2'},
364 364 ]
365 365 commit_ids = backend.create_master_repo(commits)
366 366 target = backend.create_repo(heads=['ancestor'])
367 367 source = backend.create_repo(heads=['change2'])
368 368
369 369 response = self.app.post(
370 370 route_path('pullrequest_create', repo_name=source.repo_name),
371 371 [
372 372 ('source_repo', source.repo_name),
373 373 ('source_ref', 'branch:default:' + commit_ids['change2']),
374 374 ('target_repo', target.repo_name),
375 375 ('target_ref', 'branch:default:' + commit_ids['ancestor']),
376 376 ('common_ancestor', commit_ids['ancestor']),
377 377 ('pullrequest_title', 'Title'),
378 378 ('pullrequest_desc', 'Description'),
379 379 ('description_renderer', 'markdown'),
380 380 ('__start__', 'review_members:sequence'),
381 381 ('__start__', 'reviewer:mapping'),
382 382 ('user_id', '1'),
383 383 ('__start__', 'reasons:sequence'),
384 384 ('reason', 'Some reason'),
385 385 ('__end__', 'reasons:sequence'),
386 386 ('__start__', 'rules:sequence'),
387 387 ('__end__', 'rules:sequence'),
388 388 ('mandatory', 'False'),
389 389 ('__end__', 'reviewer:mapping'),
390 390 ('__end__', 'review_members:sequence'),
391 391 ('__start__', 'revisions:sequence'),
392 392 ('revisions', commit_ids['change']),
393 393 ('revisions', commit_ids['change2']),
394 394 ('__end__', 'revisions:sequence'),
395 395 ('user', ''),
396 396 ('csrf_token', csrf_token),
397 397 ],
398 398 status=302)
399 399
400 400 location = response.headers['Location']
401 401 pull_request_id = location.rsplit('/', 1)[1]
402 402 assert pull_request_id != 'new'
403 403 pull_request = PullRequest.get(int(pull_request_id))
404 404
405 405 # check that we have now both revisions
406 406 assert pull_request.revisions == [commit_ids['change2'], commit_ids['change']]
407 407 assert pull_request.source_ref == 'branch:default:' + commit_ids['change2']
408 408 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
409 409 assert pull_request.target_ref == expected_target_ref
410 410
411 411 def test_reviewer_notifications(self, backend, csrf_token):
412 412 # We have to use the app.post for this test so it will create the
413 413 # notifications properly with the new PR
414 414 commits = [
415 415 {'message': 'ancestor',
416 416 'added': [FileNode('file_A', content='content_of_ancestor')]},
417 417 {'message': 'change',
418 418 'added': [FileNode('file_a', content='content_of_change')]},
419 419 {'message': 'change-child'},
420 420 {'message': 'ancestor-child', 'parents': ['ancestor'],
421 421 'added': [
422 422 FileNode('file_B', content='content_of_ancestor_child')]},
423 423 {'message': 'ancestor-child-2'},
424 424 ]
425 425 commit_ids = backend.create_master_repo(commits)
426 426 target = backend.create_repo(heads=['ancestor-child'])
427 427 source = backend.create_repo(heads=['change'])
428 428
429 429 response = self.app.post(
430 430 route_path('pullrequest_create', repo_name=source.repo_name),
431 431 [
432 432 ('source_repo', source.repo_name),
433 433 ('source_ref', 'branch:default:' + commit_ids['change']),
434 434 ('target_repo', target.repo_name),
435 435 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
436 436 ('common_ancestor', commit_ids['ancestor']),
437 437 ('pullrequest_title', 'Title'),
438 438 ('pullrequest_desc', 'Description'),
439 439 ('description_renderer', 'markdown'),
440 440 ('__start__', 'review_members:sequence'),
441 441 ('__start__', 'reviewer:mapping'),
442 442 ('user_id', '2'),
443 443 ('__start__', 'reasons:sequence'),
444 444 ('reason', 'Some reason'),
445 445 ('__end__', 'reasons:sequence'),
446 446 ('__start__', 'rules:sequence'),
447 447 ('__end__', 'rules:sequence'),
448 448 ('mandatory', 'False'),
449 449 ('__end__', 'reviewer:mapping'),
450 450 ('__end__', 'review_members:sequence'),
451 451 ('__start__', 'revisions:sequence'),
452 452 ('revisions', commit_ids['change']),
453 453 ('__end__', 'revisions:sequence'),
454 454 ('user', ''),
455 455 ('csrf_token', csrf_token),
456 456 ],
457 457 status=302)
458 458
459 459 location = response.headers['Location']
460 460
461 461 pull_request_id = location.rsplit('/', 1)[1]
462 462 assert pull_request_id != 'new'
463 463 pull_request = PullRequest.get(int(pull_request_id))
464 464
465 465 # Check that a notification was made
466 466 notifications = Notification.query()\
467 467 .filter(Notification.created_by == pull_request.author.user_id,
468 468 Notification.type_ == Notification.TYPE_PULL_REQUEST,
469 469 Notification.subject.contains(
470 470 "requested a pull request review. !%s" % pull_request_id))
471 471 assert len(notifications.all()) == 1
472 472
473 473 # Change reviewers and check that a notification was made
474 474 PullRequestModel().update_reviewers(
475 475 pull_request.pull_request_id, [(1, [], False, [])],
476 476 pull_request.author)
477 477 assert len(notifications.all()) == 2
478 478
479 479 def test_create_pull_request_stores_ancestor_commit_id(self, backend,
480 480 csrf_token):
481 481 commits = [
482 482 {'message': 'ancestor',
483 483 'added': [FileNode('file_A', content='content_of_ancestor')]},
484 484 {'message': 'change',
485 485 'added': [FileNode('file_a', content='content_of_change')]},
486 486 {'message': 'change-child'},
487 487 {'message': 'ancestor-child', 'parents': ['ancestor'],
488 488 'added': [
489 489 FileNode('file_B', content='content_of_ancestor_child')]},
490 490 {'message': 'ancestor-child-2'},
491 491 ]
492 492 commit_ids = backend.create_master_repo(commits)
493 493 target = backend.create_repo(heads=['ancestor-child'])
494 494 source = backend.create_repo(heads=['change'])
495 495
496 496 response = self.app.post(
497 497 route_path('pullrequest_create', repo_name=source.repo_name),
498 498 [
499 499 ('source_repo', source.repo_name),
500 500 ('source_ref', 'branch:default:' + commit_ids['change']),
501 501 ('target_repo', target.repo_name),
502 502 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
503 503 ('common_ancestor', commit_ids['ancestor']),
504 504 ('pullrequest_title', 'Title'),
505 505 ('pullrequest_desc', 'Description'),
506 506 ('description_renderer', 'markdown'),
507 507 ('__start__', 'review_members:sequence'),
508 508 ('__start__', 'reviewer:mapping'),
509 509 ('user_id', '1'),
510 510 ('__start__', 'reasons:sequence'),
511 511 ('reason', 'Some reason'),
512 512 ('__end__', 'reasons:sequence'),
513 513 ('__start__', 'rules:sequence'),
514 514 ('__end__', 'rules:sequence'),
515 515 ('mandatory', 'False'),
516 516 ('__end__', 'reviewer:mapping'),
517 517 ('__end__', 'review_members:sequence'),
518 518 ('__start__', 'revisions:sequence'),
519 519 ('revisions', commit_ids['change']),
520 520 ('__end__', 'revisions:sequence'),
521 521 ('user', ''),
522 522 ('csrf_token', csrf_token),
523 523 ],
524 524 status=302)
525 525
526 526 location = response.headers['Location']
527 527
528 528 pull_request_id = location.rsplit('/', 1)[1]
529 529 assert pull_request_id != 'new'
530 530 pull_request = PullRequest.get(int(pull_request_id))
531 531
532 532 # target_ref has to point to the ancestor's commit_id in order to
533 533 # show the correct diff
534 534 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
535 535 assert pull_request.target_ref == expected_target_ref
536 536
537 537 # Check generated diff contents
538 538 response = response.follow()
539 539 assert 'content_of_ancestor' not in response.body
540 540 assert 'content_of_ancestor-child' not in response.body
541 541 assert 'content_of_change' in response.body
542 542
543 543 def test_merge_pull_request_enabled(self, pr_util, csrf_token):
544 544 # Clear any previous calls to rcextensions
545 545 rhodecode.EXTENSIONS.calls.clear()
546 546
547 547 pull_request = pr_util.create_pull_request(
548 548 approved=True, mergeable=True)
549 549 pull_request_id = pull_request.pull_request_id
550 550 repo_name = pull_request.target_repo.scm_instance().name,
551 551
552 552 url = route_path('pullrequest_merge',
553 553 repo_name=str(repo_name[0]),
554 554 pull_request_id=pull_request_id)
555 555 response = self.app.post(url, params={'csrf_token': csrf_token}).follow()
556 556
557 557 pull_request = PullRequest.get(pull_request_id)
558 558
559 559 assert response.status_int == 200
560 560 assert pull_request.is_closed()
561 561 assert_pull_request_status(
562 562 pull_request, ChangesetStatus.STATUS_APPROVED)
563 563
564 564 # Check the relevant log entries were added
565 565 user_logs = UserLog.query().order_by(UserLog.user_log_id.desc()).limit(3)
566 566 actions = [log.action for log in user_logs]
567 567 pr_commit_ids = PullRequestModel()._get_commit_ids(pull_request)
568 568 expected_actions = [
569 569 u'repo.pull_request.close',
570 570 u'repo.pull_request.merge',
571 571 u'repo.pull_request.comment.create'
572 572 ]
573 573 assert actions == expected_actions
574 574
575 575 user_logs = UserLog.query().order_by(UserLog.user_log_id.desc()).limit(4)
576 576 actions = [log for log in user_logs]
577 577 assert actions[-1].action == 'user.push'
578 578 assert actions[-1].action_data['commit_ids'] == pr_commit_ids
579 579
580 580 # Check post_push rcextension was really executed
581 581 push_calls = rhodecode.EXTENSIONS.calls['_push_hook']
582 582 assert len(push_calls) == 1
583 583 unused_last_call_args, last_call_kwargs = push_calls[0]
584 584 assert last_call_kwargs['action'] == 'push'
585 585 assert last_call_kwargs['commit_ids'] == pr_commit_ids
586 586
587 587 def test_merge_pull_request_disabled(self, pr_util, csrf_token):
588 588 pull_request = pr_util.create_pull_request(mergeable=False)
589 589 pull_request_id = pull_request.pull_request_id
590 590 pull_request = PullRequest.get(pull_request_id)
591 591
592 592 response = self.app.post(
593 593 route_path('pullrequest_merge',
594 594 repo_name=pull_request.target_repo.scm_instance().name,
595 595 pull_request_id=pull_request.pull_request_id),
596 596 params={'csrf_token': csrf_token}).follow()
597 597
598 598 assert response.status_int == 200
599 599 response.mustcontain(
600 600 'Merge is not currently possible because of below failed checks.')
601 601 response.mustcontain('Server-side pull request merging is disabled.')
602 602
603 603 @pytest.mark.skip_backends('svn')
604 604 def test_merge_pull_request_not_approved(self, pr_util, csrf_token):
605 605 pull_request = pr_util.create_pull_request(mergeable=True)
606 606 pull_request_id = pull_request.pull_request_id
607 607 repo_name = pull_request.target_repo.scm_instance().name
608 608
609 609 response = self.app.post(
610 610 route_path('pullrequest_merge',
611 611 repo_name=repo_name, pull_request_id=pull_request_id),
612 612 params={'csrf_token': csrf_token}).follow()
613 613
614 614 assert response.status_int == 200
615 615
616 616 response.mustcontain(
617 617 'Merge is not currently possible because of below failed checks.')
618 618 response.mustcontain('Pull request reviewer approval is pending.')
619 619
620 620 def test_merge_pull_request_renders_failure_reason(
621 621 self, user_regular, csrf_token, pr_util):
622 622 pull_request = pr_util.create_pull_request(mergeable=True, approved=True)
623 623 pull_request_id = pull_request.pull_request_id
624 624 repo_name = pull_request.target_repo.scm_instance().name
625 625
626 626 merge_resp = MergeResponse(True, False, 'STUB_COMMIT_ID',
627 627 MergeFailureReason.PUSH_FAILED,
628 628 metadata={'target': 'shadow repo',
629 629 'merge_commit': 'xxx'})
630 630 model_patcher = mock.patch.multiple(
631 631 PullRequestModel,
632 632 merge_repo=mock.Mock(return_value=merge_resp),
633 633 merge_status=mock.Mock(return_value=(True, 'WRONG_MESSAGE')))
634 634
635 635 with model_patcher:
636 636 response = self.app.post(
637 637 route_path('pullrequest_merge',
638 638 repo_name=repo_name,
639 639 pull_request_id=pull_request_id),
640 640 params={'csrf_token': csrf_token}, status=302)
641 641
642 642 merge_resp = MergeResponse(True, True, '', MergeFailureReason.PUSH_FAILED,
643 643 metadata={'target': 'shadow repo',
644 644 'merge_commit': 'xxx'})
645 645 assert_session_flash(response, merge_resp.merge_status_message)
646 646
647 647 def test_update_source_revision(self, backend, csrf_token):
648 648 commits = [
649 649 {'message': 'ancestor'},
650 650 {'message': 'change'},
651 651 {'message': 'change-2'},
652 652 ]
653 653 commit_ids = backend.create_master_repo(commits)
654 654 target = backend.create_repo(heads=['ancestor'])
655 655 source = backend.create_repo(heads=['change'])
656 656
657 657 # create pr from a in source to A in target
658 658 pull_request = PullRequest()
659 659
660 660 pull_request.source_repo = source
661 661 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
662 662 branch=backend.default_branch_name, commit_id=commit_ids['change'])
663 663
664 664 pull_request.target_repo = target
665 665 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
666 666 branch=backend.default_branch_name, commit_id=commit_ids['ancestor'])
667 667
668 668 pull_request.revisions = [commit_ids['change']]
669 669 pull_request.title = u"Test"
670 670 pull_request.description = u"Description"
671 671 pull_request.author = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
672 672 pull_request.pull_request_state = PullRequest.STATE_CREATED
673 673 Session().add(pull_request)
674 674 Session().commit()
675 675 pull_request_id = pull_request.pull_request_id
676 676
677 677 # source has ancestor - change - change-2
678 678 backend.pull_heads(source, heads=['change-2'])
679 679
680 680 # update PR
681 681 self.app.post(
682 682 route_path('pullrequest_update',
683 683 repo_name=target.repo_name, pull_request_id=pull_request_id),
684 684 params={'update_commits': 'true', 'csrf_token': csrf_token})
685 685
686 686 response = self.app.get(
687 687 route_path('pullrequest_show',
688 688 repo_name=target.repo_name,
689 689 pull_request_id=pull_request.pull_request_id))
690 690
691 691 assert response.status_int == 200
692 692 assert 'Pull request updated to' in response.body
693 693 assert 'with 1 added, 0 removed commits.' in response.body
694 694
695 695 # check that we have now both revisions
696 696 pull_request = PullRequest.get(pull_request_id)
697 697 assert pull_request.revisions == [commit_ids['change-2'], commit_ids['change']]
698 698
699 699 def test_update_target_revision(self, backend, csrf_token):
700 700 commits = [
701 701 {'message': 'ancestor'},
702 702 {'message': 'change'},
703 703 {'message': 'ancestor-new', 'parents': ['ancestor']},
704 704 {'message': 'change-rebased'},
705 705 ]
706 706 commit_ids = backend.create_master_repo(commits)
707 707 target = backend.create_repo(heads=['ancestor'])
708 708 source = backend.create_repo(heads=['change'])
709 709
710 710 # create pr from a in source to A in target
711 711 pull_request = PullRequest()
712 712
713 713 pull_request.source_repo = source
714 714 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
715 715 branch=backend.default_branch_name, commit_id=commit_ids['change'])
716 716
717 717 pull_request.target_repo = target
718 718 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
719 719 branch=backend.default_branch_name, commit_id=commit_ids['ancestor'])
720 720
721 721 pull_request.revisions = [commit_ids['change']]
722 722 pull_request.title = u"Test"
723 723 pull_request.description = u"Description"
724 724 pull_request.author = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
725 725 pull_request.pull_request_state = PullRequest.STATE_CREATED
726 726
727 727 Session().add(pull_request)
728 728 Session().commit()
729 729 pull_request_id = pull_request.pull_request_id
730 730
731 731 # target has ancestor - ancestor-new
732 732 # source has ancestor - ancestor-new - change-rebased
733 733 backend.pull_heads(target, heads=['ancestor-new'])
734 734 backend.pull_heads(source, heads=['change-rebased'])
735 735
736 736 # update PR
737 737 url = route_path('pullrequest_update',
738 738 repo_name=target.repo_name,
739 739 pull_request_id=pull_request_id)
740 740 self.app.post(url,
741 741 params={'update_commits': 'true', 'csrf_token': csrf_token},
742 742 status=200)
743 743
744 744 # check that we have now both revisions
745 745 pull_request = PullRequest.get(pull_request_id)
746 746 assert pull_request.revisions == [commit_ids['change-rebased']]
747 747 assert pull_request.target_ref == 'branch:{branch}:{commit_id}'.format(
748 748 branch=backend.default_branch_name, commit_id=commit_ids['ancestor-new'])
749 749
750 750 response = self.app.get(
751 751 route_path('pullrequest_show',
752 752 repo_name=target.repo_name,
753 753 pull_request_id=pull_request.pull_request_id))
754 754 assert response.status_int == 200
755 755 assert 'Pull request updated to' in response.body
756 756 assert 'with 1 added, 1 removed commits.' in response.body
757 757
758 758 def test_update_target_revision_with_removal_of_1_commit_git(self, backend_git, csrf_token):
759 759 backend = backend_git
760 760 commits = [
761 761 {'message': 'master-commit-1'},
762 762 {'message': 'master-commit-2-change-1'},
763 763 {'message': 'master-commit-3-change-2'},
764 764
765 765 {'message': 'feat-commit-1', 'parents': ['master-commit-1']},
766 766 {'message': 'feat-commit-2'},
767 767 ]
768 768 commit_ids = backend.create_master_repo(commits)
769 769 target = backend.create_repo(heads=['master-commit-3-change-2'])
770 770 source = backend.create_repo(heads=['feat-commit-2'])
771 771
772 772 # create pr from a in source to A in target
773 773 pull_request = PullRequest()
774 774 pull_request.source_repo = source
775 775
776 776 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
777 777 branch=backend.default_branch_name,
778 778 commit_id=commit_ids['master-commit-3-change-2'])
779 779
780 780 pull_request.target_repo = target
781 781 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
782 782 branch=backend.default_branch_name, commit_id=commit_ids['feat-commit-2'])
783 783
784 784 pull_request.revisions = [
785 785 commit_ids['feat-commit-1'],
786 786 commit_ids['feat-commit-2']
787 787 ]
788 788 pull_request.title = u"Test"
789 789 pull_request.description = u"Description"
790 790 pull_request.author = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
791 791 pull_request.pull_request_state = PullRequest.STATE_CREATED
792 792 Session().add(pull_request)
793 793 Session().commit()
794 794 pull_request_id = pull_request.pull_request_id
795 795
796 796 # PR is created, now we simulate a force-push into target,
797 797 # that drops a 2 last commits
798 798 vcsrepo = target.scm_instance()
799 799 vcsrepo.config.clear_section('hooks')
800 800 vcsrepo.run_git_command(['reset', '--soft', 'HEAD~2'])
801 801
802 802 # update PR
803 803 url = route_path('pullrequest_update',
804 804 repo_name=target.repo_name,
805 805 pull_request_id=pull_request_id)
806 806 self.app.post(url,
807 807 params={'update_commits': 'true', 'csrf_token': csrf_token},
808 808 status=200)
809 809
810 810 response = self.app.get(route_path('pullrequest_new', repo_name=target.repo_name))
811 811 assert response.status_int == 200
812 812 response.mustcontain('Pull request updated to')
813 813 response.mustcontain('with 0 added, 0 removed commits.')
814 814
815 815 def test_update_of_ancestor_reference(self, backend, csrf_token):
816 816 commits = [
817 817 {'message': 'ancestor'},
818 818 {'message': 'change'},
819 819 {'message': 'change-2'},
820 820 {'message': 'ancestor-new', 'parents': ['ancestor']},
821 821 {'message': 'change-rebased'},
822 822 ]
823 823 commit_ids = backend.create_master_repo(commits)
824 824 target = backend.create_repo(heads=['ancestor'])
825 825 source = backend.create_repo(heads=['change'])
826 826
827 827 # create pr from a in source to A in target
828 828 pull_request = PullRequest()
829 829 pull_request.source_repo = source
830 830
831 831 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
832 832 branch=backend.default_branch_name, commit_id=commit_ids['change'])
833 833 pull_request.target_repo = target
834 834 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
835 835 branch=backend.default_branch_name, commit_id=commit_ids['ancestor'])
836 836 pull_request.revisions = [commit_ids['change']]
837 837 pull_request.title = u"Test"
838 838 pull_request.description = u"Description"
839 839 pull_request.author = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
840 840 pull_request.pull_request_state = PullRequest.STATE_CREATED
841 841 Session().add(pull_request)
842 842 Session().commit()
843 843 pull_request_id = pull_request.pull_request_id
844 844
845 845 # target has ancestor - ancestor-new
846 846 # source has ancestor - ancestor-new - change-rebased
847 847 backend.pull_heads(target, heads=['ancestor-new'])
848 848 backend.pull_heads(source, heads=['change-rebased'])
849 849
850 850 # update PR
851 851 self.app.post(
852 852 route_path('pullrequest_update',
853 853 repo_name=target.repo_name, pull_request_id=pull_request_id),
854 854 params={'update_commits': 'true', 'csrf_token': csrf_token},
855 855 status=200)
856 856
857 857 # Expect the target reference to be updated correctly
858 858 pull_request = PullRequest.get(pull_request_id)
859 859 assert pull_request.revisions == [commit_ids['change-rebased']]
860 860 expected_target_ref = 'branch:{branch}:{commit_id}'.format(
861 861 branch=backend.default_branch_name,
862 862 commit_id=commit_ids['ancestor-new'])
863 863 assert pull_request.target_ref == expected_target_ref
864 864
865 865 def test_remove_pull_request_branch(self, backend_git, csrf_token):
866 866 branch_name = 'development'
867 867 commits = [
868 868 {'message': 'initial-commit'},
869 869 {'message': 'old-feature'},
870 870 {'message': 'new-feature', 'branch': branch_name},
871 871 ]
872 872 repo = backend_git.create_repo(commits)
873 873 repo_name = repo.repo_name
874 874 commit_ids = backend_git.commit_ids
875 875
876 876 pull_request = PullRequest()
877 877 pull_request.source_repo = repo
878 878 pull_request.target_repo = repo
879 879 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
880 880 branch=branch_name, commit_id=commit_ids['new-feature'])
881 881 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
882 882 branch=backend_git.default_branch_name, commit_id=commit_ids['old-feature'])
883 883 pull_request.revisions = [commit_ids['new-feature']]
884 884 pull_request.title = u"Test"
885 885 pull_request.description = u"Description"
886 886 pull_request.author = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
887 887 pull_request.pull_request_state = PullRequest.STATE_CREATED
888 888 Session().add(pull_request)
889 889 Session().commit()
890 890
891 891 pull_request_id = pull_request.pull_request_id
892 892
893 893 vcs = repo.scm_instance()
894 894 vcs.remove_ref('refs/heads/{}'.format(branch_name))
895 895
896 896 response = self.app.get(route_path(
897 897 'pullrequest_show',
898 898 repo_name=repo_name,
899 899 pull_request_id=pull_request_id))
900 900
901 901 assert response.status_int == 200
902 902
903 903 response.assert_response().element_contains(
904 904 '#changeset_compare_view_content .alert strong',
905 905 'Missing commits')
906 906 response.assert_response().element_contains(
907 907 '#changeset_compare_view_content .alert',
908 908 'This pull request cannot be displayed, because one or more'
909 909 ' commits no longer exist in the source repository.')
910 910
911 911 def test_strip_commits_from_pull_request(
912 912 self, backend, pr_util, csrf_token):
913 913 commits = [
914 914 {'message': 'initial-commit'},
915 915 {'message': 'old-feature'},
916 916 {'message': 'new-feature', 'parents': ['initial-commit']},
917 917 ]
918 918 pull_request = pr_util.create_pull_request(
919 919 commits, target_head='initial-commit', source_head='new-feature',
920 920 revisions=['new-feature'])
921 921
922 922 vcs = pr_util.source_repository.scm_instance()
923 923 if backend.alias == 'git':
924 924 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
925 925 else:
926 926 vcs.strip(pr_util.commit_ids['new-feature'])
927 927
928 928 response = self.app.get(route_path(
929 929 'pullrequest_show',
930 930 repo_name=pr_util.target_repository.repo_name,
931 931 pull_request_id=pull_request.pull_request_id))
932 932
933 933 assert response.status_int == 200
934 934
935 935 response.assert_response().element_contains(
936 936 '#changeset_compare_view_content .alert strong',
937 937 'Missing commits')
938 938 response.assert_response().element_contains(
939 939 '#changeset_compare_view_content .alert',
940 940 'This pull request cannot be displayed, because one or more'
941 941 ' commits no longer exist in the source repository.')
942 942 response.assert_response().element_contains(
943 943 '#update_commits',
944 944 'Update commits')
945 945
946 946 def test_strip_commits_and_update(
947 947 self, backend, pr_util, csrf_token):
948 948 commits = [
949 949 {'message': 'initial-commit'},
950 950 {'message': 'old-feature'},
951 951 {'message': 'new-feature', 'parents': ['old-feature']},
952 952 ]
953 953 pull_request = pr_util.create_pull_request(
954 954 commits, target_head='old-feature', source_head='new-feature',
955 955 revisions=['new-feature'], mergeable=True)
956 956
957 957 vcs = pr_util.source_repository.scm_instance()
958 958 if backend.alias == 'git':
959 959 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
960 960 else:
961 961 vcs.strip(pr_util.commit_ids['new-feature'])
962 962
963 963 url = route_path('pullrequest_update',
964 964 repo_name=pull_request.target_repo.repo_name,
965 965 pull_request_id=pull_request.pull_request_id)
966 966 response = self.app.post(url,
967 967 params={'update_commits': 'true',
968 968 'csrf_token': csrf_token})
969 969
970 970 assert response.status_int == 200
971 assert response.body == 'true'
971 assert response.body == '{"response": true, "redirect_url": null}'
972 972
973 973 # Make sure that after update, it won't raise 500 errors
974 974 response = self.app.get(route_path(
975 975 'pullrequest_show',
976 976 repo_name=pr_util.target_repository.repo_name,
977 977 pull_request_id=pull_request.pull_request_id))
978 978
979 979 assert response.status_int == 200
980 980 response.assert_response().element_contains(
981 981 '#changeset_compare_view_content .alert strong',
982 982 'Missing commits')
983 983
984 984 def test_branch_is_a_link(self, pr_util):
985 985 pull_request = pr_util.create_pull_request()
986 986 pull_request.source_ref = 'branch:origin:1234567890abcdef'
987 987 pull_request.target_ref = 'branch:target:abcdef1234567890'
988 988 Session().add(pull_request)
989 989 Session().commit()
990 990
991 991 response = self.app.get(route_path(
992 992 'pullrequest_show',
993 993 repo_name=pull_request.target_repo.scm_instance().name,
994 994 pull_request_id=pull_request.pull_request_id))
995 995 assert response.status_int == 200
996 996
997 997 origin = response.assert_response().get_element('.pr-origininfo .tag')
998 998 origin_children = origin.getchildren()
999 999 assert len(origin_children) == 1
1000 1000 target = response.assert_response().get_element('.pr-targetinfo .tag')
1001 1001 target_children = target.getchildren()
1002 1002 assert len(target_children) == 1
1003 1003
1004 1004 expected_origin_link = route_path(
1005 1005 'repo_commits',
1006 1006 repo_name=pull_request.source_repo.scm_instance().name,
1007 1007 params=dict(branch='origin'))
1008 1008 expected_target_link = route_path(
1009 1009 'repo_commits',
1010 1010 repo_name=pull_request.target_repo.scm_instance().name,
1011 1011 params=dict(branch='target'))
1012 1012 assert origin_children[0].attrib['href'] == expected_origin_link
1013 1013 assert origin_children[0].text == 'branch: origin'
1014 1014 assert target_children[0].attrib['href'] == expected_target_link
1015 1015 assert target_children[0].text == 'branch: target'
1016 1016
1017 1017 def test_bookmark_is_not_a_link(self, pr_util):
1018 1018 pull_request = pr_util.create_pull_request()
1019 1019 pull_request.source_ref = 'bookmark:origin:1234567890abcdef'
1020 1020 pull_request.target_ref = 'bookmark:target:abcdef1234567890'
1021 1021 Session().add(pull_request)
1022 1022 Session().commit()
1023 1023
1024 1024 response = self.app.get(route_path(
1025 1025 'pullrequest_show',
1026 1026 repo_name=pull_request.target_repo.scm_instance().name,
1027 1027 pull_request_id=pull_request.pull_request_id))
1028 1028 assert response.status_int == 200
1029 1029
1030 1030 origin = response.assert_response().get_element('.pr-origininfo .tag')
1031 1031 assert origin.text.strip() == 'bookmark: origin'
1032 1032 assert origin.getchildren() == []
1033 1033
1034 1034 target = response.assert_response().get_element('.pr-targetinfo .tag')
1035 1035 assert target.text.strip() == 'bookmark: target'
1036 1036 assert target.getchildren() == []
1037 1037
1038 1038 def test_tag_is_not_a_link(self, pr_util):
1039 1039 pull_request = pr_util.create_pull_request()
1040 1040 pull_request.source_ref = 'tag:origin:1234567890abcdef'
1041 1041 pull_request.target_ref = 'tag:target:abcdef1234567890'
1042 1042 Session().add(pull_request)
1043 1043 Session().commit()
1044 1044
1045 1045 response = self.app.get(route_path(
1046 1046 'pullrequest_show',
1047 1047 repo_name=pull_request.target_repo.scm_instance().name,
1048 1048 pull_request_id=pull_request.pull_request_id))
1049 1049 assert response.status_int == 200
1050 1050
1051 1051 origin = response.assert_response().get_element('.pr-origininfo .tag')
1052 1052 assert origin.text.strip() == 'tag: origin'
1053 1053 assert origin.getchildren() == []
1054 1054
1055 1055 target = response.assert_response().get_element('.pr-targetinfo .tag')
1056 1056 assert target.text.strip() == 'tag: target'
1057 1057 assert target.getchildren() == []
1058 1058
1059 1059 @pytest.mark.parametrize('mergeable', [True, False])
1060 1060 def test_shadow_repository_link(
1061 1061 self, mergeable, pr_util, http_host_only_stub):
1062 1062 """
1063 1063 Check that the pull request summary page displays a link to the shadow
1064 1064 repository if the pull request is mergeable. If it is not mergeable
1065 1065 the link should not be displayed.
1066 1066 """
1067 1067 pull_request = pr_util.create_pull_request(
1068 1068 mergeable=mergeable, enable_notifications=False)
1069 1069 target_repo = pull_request.target_repo.scm_instance()
1070 1070 pr_id = pull_request.pull_request_id
1071 1071 shadow_url = '{host}/{repo}/pull-request/{pr_id}/repository'.format(
1072 1072 host=http_host_only_stub, repo=target_repo.name, pr_id=pr_id)
1073 1073
1074 1074 response = self.app.get(route_path(
1075 1075 'pullrequest_show',
1076 1076 repo_name=target_repo.name,
1077 1077 pull_request_id=pr_id))
1078 1078
1079 1079 if mergeable:
1080 1080 response.assert_response().element_value_contains(
1081 1081 'input.pr-mergeinfo', shadow_url)
1082 1082 response.assert_response().element_value_contains(
1083 1083 'input.pr-mergeinfo ', 'pr-merge')
1084 1084 else:
1085 1085 response.assert_response().no_element_exists('.pr-mergeinfo')
1086 1086
1087 1087
1088 1088 @pytest.mark.usefixtures('app')
1089 1089 @pytest.mark.backends("git", "hg")
1090 1090 class TestPullrequestsControllerDelete(object):
1091 1091 def test_pull_request_delete_button_permissions_admin(
1092 1092 self, autologin_user, user_admin, pr_util):
1093 1093 pull_request = pr_util.create_pull_request(
1094 1094 author=user_admin.username, enable_notifications=False)
1095 1095
1096 1096 response = self.app.get(route_path(
1097 1097 'pullrequest_show',
1098 1098 repo_name=pull_request.target_repo.scm_instance().name,
1099 1099 pull_request_id=pull_request.pull_request_id))
1100 1100
1101 1101 response.mustcontain('id="delete_pullrequest"')
1102 1102 response.mustcontain('Confirm to delete this pull request')
1103 1103
1104 1104 def test_pull_request_delete_button_permissions_owner(
1105 1105 self, autologin_regular_user, user_regular, pr_util):
1106 1106 pull_request = pr_util.create_pull_request(
1107 1107 author=user_regular.username, enable_notifications=False)
1108 1108
1109 1109 response = self.app.get(route_path(
1110 1110 'pullrequest_show',
1111 1111 repo_name=pull_request.target_repo.scm_instance().name,
1112 1112 pull_request_id=pull_request.pull_request_id))
1113 1113
1114 1114 response.mustcontain('id="delete_pullrequest"')
1115 1115 response.mustcontain('Confirm to delete this pull request')
1116 1116
1117 1117 def test_pull_request_delete_button_permissions_forbidden(
1118 1118 self, autologin_regular_user, user_regular, user_admin, pr_util):
1119 1119 pull_request = pr_util.create_pull_request(
1120 1120 author=user_admin.username, enable_notifications=False)
1121 1121
1122 1122 response = self.app.get(route_path(
1123 1123 'pullrequest_show',
1124 1124 repo_name=pull_request.target_repo.scm_instance().name,
1125 1125 pull_request_id=pull_request.pull_request_id))
1126 1126 response.mustcontain(no=['id="delete_pullrequest"'])
1127 1127 response.mustcontain(no=['Confirm to delete this pull request'])
1128 1128
1129 1129 def test_pull_request_delete_button_permissions_can_update_cannot_delete(
1130 1130 self, autologin_regular_user, user_regular, user_admin, pr_util,
1131 1131 user_util):
1132 1132
1133 1133 pull_request = pr_util.create_pull_request(
1134 1134 author=user_admin.username, enable_notifications=False)
1135 1135
1136 1136 user_util.grant_user_permission_to_repo(
1137 1137 pull_request.target_repo, user_regular,
1138 1138 'repository.write')
1139 1139
1140 1140 response = self.app.get(route_path(
1141 1141 'pullrequest_show',
1142 1142 repo_name=pull_request.target_repo.scm_instance().name,
1143 1143 pull_request_id=pull_request.pull_request_id))
1144 1144
1145 1145 response.mustcontain('id="open_edit_pullrequest"')
1146 1146 response.mustcontain('id="delete_pullrequest"')
1147 1147 response.mustcontain(no=['Confirm to delete this pull request'])
1148 1148
1149 1149 def test_delete_comment_returns_404_if_comment_does_not_exist(
1150 1150 self, autologin_user, pr_util, user_admin, csrf_token, xhr_header):
1151 1151
1152 1152 pull_request = pr_util.create_pull_request(
1153 1153 author=user_admin.username, enable_notifications=False)
1154 1154
1155 1155 self.app.post(
1156 1156 route_path(
1157 1157 'pullrequest_comment_delete',
1158 1158 repo_name=pull_request.target_repo.scm_instance().name,
1159 1159 pull_request_id=pull_request.pull_request_id,
1160 1160 comment_id=1024404),
1161 1161 extra_environ=xhr_header,
1162 1162 params={'csrf_token': csrf_token},
1163 1163 status=404
1164 1164 )
1165 1165
1166 1166 def test_delete_comment(
1167 1167 self, autologin_user, pr_util, user_admin, csrf_token, xhr_header):
1168 1168
1169 1169 pull_request = pr_util.create_pull_request(
1170 1170 author=user_admin.username, enable_notifications=False)
1171 1171 comment = pr_util.create_comment()
1172 1172 comment_id = comment.comment_id
1173 1173
1174 1174 response = self.app.post(
1175 1175 route_path(
1176 1176 'pullrequest_comment_delete',
1177 1177 repo_name=pull_request.target_repo.scm_instance().name,
1178 1178 pull_request_id=pull_request.pull_request_id,
1179 1179 comment_id=comment_id),
1180 1180 extra_environ=xhr_header,
1181 1181 params={'csrf_token': csrf_token},
1182 1182 status=200
1183 1183 )
1184 1184 assert response.body == 'true'
1185 1185
1186 1186 @pytest.mark.parametrize('url_type', [
1187 1187 'pullrequest_new',
1188 1188 'pullrequest_create',
1189 1189 'pullrequest_update',
1190 1190 'pullrequest_merge',
1191 1191 ])
1192 1192 def test_pull_request_is_forbidden_on_archived_repo(
1193 1193 self, autologin_user, backend, xhr_header, user_util, url_type):
1194 1194
1195 1195 # create a temporary repo
1196 1196 source = user_util.create_repo(repo_type=backend.alias)
1197 1197 repo_name = source.repo_name
1198 1198 repo = Repository.get_by_repo_name(repo_name)
1199 1199 repo.archived = True
1200 1200 Session().commit()
1201 1201
1202 1202 response = self.app.get(
1203 1203 route_path(url_type, repo_name=repo_name, pull_request_id=1), status=302)
1204 1204
1205 1205 msg = 'Action not supported for archived repository.'
1206 1206 assert_session_flash(response, msg)
1207 1207
1208 1208
1209 1209 def assert_pull_request_status(pull_request, expected_status):
1210 1210 status = ChangesetStatusModel().calculated_review_status(pull_request=pull_request)
1211 1211 assert status == expected_status
1212 1212
1213 1213
1214 1214 @pytest.mark.parametrize('route', ['pullrequest_new', 'pullrequest_create'])
1215 1215 @pytest.mark.usefixtures("autologin_user")
1216 1216 def test_forbidde_to_repo_summary_for_svn_repositories(backend_svn, app, route):
1217 1217 app.get(route_path(route, repo_name=backend_svn.repo_name), status=404)
@@ -1,1470 +1,1481 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import collections
23 23
24 24 import formencode
25 25 import formencode.htmlfill
26 26 import peppercorn
27 27 from pyramid.httpexceptions import (
28 28 HTTPFound, HTTPNotFound, HTTPForbidden, HTTPBadRequest)
29 29 from pyramid.view import view_config
30 30 from pyramid.renderers import render
31 31
32 32 from rhodecode.apps._base import RepoAppView, DataGridAppView
33 33
34 34 from rhodecode.lib import helpers as h, diffs, codeblocks, channelstream
35 35 from rhodecode.lib.base import vcs_operation_context
36 36 from rhodecode.lib.diffs import load_cached_diff, cache_diff, diff_cache_exist
37 37 from rhodecode.lib.ext_json import json
38 38 from rhodecode.lib.auth import (
39 39 LoginRequired, HasRepoPermissionAny, HasRepoPermissionAnyDecorator,
40 40 NotAnonymous, CSRFRequired)
41 41 from rhodecode.lib.utils2 import str2bool, safe_str, safe_unicode
42 42 from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason
43 43 from rhodecode.lib.vcs.exceptions import (CommitDoesNotExistError,
44 44 RepositoryRequirementError, EmptyRepositoryError)
45 45 from rhodecode.model.changeset_status import ChangesetStatusModel
46 46 from rhodecode.model.comment import CommentsModel
47 47 from rhodecode.model.db import (func, or_, PullRequest, PullRequestVersion,
48 48 ChangesetComment, ChangesetStatus, Repository)
49 49 from rhodecode.model.forms import PullRequestForm
50 50 from rhodecode.model.meta import Session
51 51 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
52 52 from rhodecode.model.scm import ScmModel
53 53
54 54 log = logging.getLogger(__name__)
55 55
56 56
57 57 class RepoPullRequestsView(RepoAppView, DataGridAppView):
58 58
59 59 def load_default_context(self):
60 60 c = self._get_local_tmpl_context(include_app_defaults=True)
61 61 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
62 62 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
63 63 # backward compat., we use for OLD PRs a plain renderer
64 64 c.renderer = 'plain'
65 65 return c
66 66
67 67 def _get_pull_requests_list(
68 68 self, repo_name, source, filter_type, opened_by, statuses):
69 69
70 70 draw, start, limit = self._extract_chunk(self.request)
71 71 search_q, order_by, order_dir = self._extract_ordering(self.request)
72 72 _render = self.request.get_partial_renderer(
73 73 'rhodecode:templates/data_table/_dt_elements.mako')
74 74
75 75 # pagination
76 76
77 77 if filter_type == 'awaiting_review':
78 78 pull_requests = PullRequestModel().get_awaiting_review(
79 79 repo_name, search_q=search_q, source=source, opened_by=opened_by,
80 80 statuses=statuses, offset=start, length=limit,
81 81 order_by=order_by, order_dir=order_dir)
82 82 pull_requests_total_count = PullRequestModel().count_awaiting_review(
83 83 repo_name, search_q=search_q, source=source, statuses=statuses,
84 84 opened_by=opened_by)
85 85 elif filter_type == 'awaiting_my_review':
86 86 pull_requests = PullRequestModel().get_awaiting_my_review(
87 87 repo_name, search_q=search_q, source=source, opened_by=opened_by,
88 88 user_id=self._rhodecode_user.user_id, statuses=statuses,
89 89 offset=start, length=limit, order_by=order_by,
90 90 order_dir=order_dir)
91 91 pull_requests_total_count = PullRequestModel().count_awaiting_my_review(
92 92 repo_name, search_q=search_q, source=source, user_id=self._rhodecode_user.user_id,
93 93 statuses=statuses, opened_by=opened_by)
94 94 else:
95 95 pull_requests = PullRequestModel().get_all(
96 96 repo_name, search_q=search_q, source=source, opened_by=opened_by,
97 97 statuses=statuses, offset=start, length=limit,
98 98 order_by=order_by, order_dir=order_dir)
99 99 pull_requests_total_count = PullRequestModel().count_all(
100 100 repo_name, search_q=search_q, source=source, statuses=statuses,
101 101 opened_by=opened_by)
102 102
103 103 data = []
104 104 comments_model = CommentsModel()
105 105 for pr in pull_requests:
106 106 comments = comments_model.get_all_comments(
107 107 self.db_repo.repo_id, pull_request=pr)
108 108
109 109 data.append({
110 110 'name': _render('pullrequest_name',
111 111 pr.pull_request_id, pr.target_repo.repo_name),
112 112 'name_raw': pr.pull_request_id,
113 113 'status': _render('pullrequest_status',
114 114 pr.calculated_review_status()),
115 115 'title': _render('pullrequest_title', pr.title, pr.description),
116 116 'description': h.escape(pr.description),
117 117 'updated_on': _render('pullrequest_updated_on',
118 118 h.datetime_to_time(pr.updated_on)),
119 119 'updated_on_raw': h.datetime_to_time(pr.updated_on),
120 120 'created_on': _render('pullrequest_updated_on',
121 121 h.datetime_to_time(pr.created_on)),
122 122 'created_on_raw': h.datetime_to_time(pr.created_on),
123 123 'state': pr.pull_request_state,
124 124 'author': _render('pullrequest_author',
125 125 pr.author.full_contact, ),
126 126 'author_raw': pr.author.full_name,
127 127 'comments': _render('pullrequest_comments', len(comments)),
128 128 'comments_raw': len(comments),
129 129 'closed': pr.is_closed(),
130 130 })
131 131
132 132 data = ({
133 133 'draw': draw,
134 134 'data': data,
135 135 'recordsTotal': pull_requests_total_count,
136 136 'recordsFiltered': pull_requests_total_count,
137 137 })
138 138 return data
139 139
140 140 @LoginRequired()
141 141 @HasRepoPermissionAnyDecorator(
142 142 'repository.read', 'repository.write', 'repository.admin')
143 143 @view_config(
144 144 route_name='pullrequest_show_all', request_method='GET',
145 145 renderer='rhodecode:templates/pullrequests/pullrequests.mako')
146 146 def pull_request_list(self):
147 147 c = self.load_default_context()
148 148
149 149 req_get = self.request.GET
150 150 c.source = str2bool(req_get.get('source'))
151 151 c.closed = str2bool(req_get.get('closed'))
152 152 c.my = str2bool(req_get.get('my'))
153 153 c.awaiting_review = str2bool(req_get.get('awaiting_review'))
154 154 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
155 155
156 156 c.active = 'open'
157 157 if c.my:
158 158 c.active = 'my'
159 159 if c.closed:
160 160 c.active = 'closed'
161 161 if c.awaiting_review and not c.source:
162 162 c.active = 'awaiting'
163 163 if c.source and not c.awaiting_review:
164 164 c.active = 'source'
165 165 if c.awaiting_my_review:
166 166 c.active = 'awaiting_my'
167 167
168 168 return self._get_template_context(c)
169 169
170 170 @LoginRequired()
171 171 @HasRepoPermissionAnyDecorator(
172 172 'repository.read', 'repository.write', 'repository.admin')
173 173 @view_config(
174 174 route_name='pullrequest_show_all_data', request_method='GET',
175 175 renderer='json_ext', xhr=True)
176 176 def pull_request_list_data(self):
177 177 self.load_default_context()
178 178
179 179 # additional filters
180 180 req_get = self.request.GET
181 181 source = str2bool(req_get.get('source'))
182 182 closed = str2bool(req_get.get('closed'))
183 183 my = str2bool(req_get.get('my'))
184 184 awaiting_review = str2bool(req_get.get('awaiting_review'))
185 185 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
186 186
187 187 filter_type = 'awaiting_review' if awaiting_review \
188 188 else 'awaiting_my_review' if awaiting_my_review \
189 189 else None
190 190
191 191 opened_by = None
192 192 if my:
193 193 opened_by = [self._rhodecode_user.user_id]
194 194
195 195 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
196 196 if closed:
197 197 statuses = [PullRequest.STATUS_CLOSED]
198 198
199 199 data = self._get_pull_requests_list(
200 200 repo_name=self.db_repo_name, source=source,
201 201 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
202 202
203 203 return data
204 204
205 205 def _is_diff_cache_enabled(self, target_repo):
206 206 caching_enabled = self._get_general_setting(
207 207 target_repo, 'rhodecode_diff_cache')
208 208 log.debug('Diff caching enabled: %s', caching_enabled)
209 209 return caching_enabled
210 210
211 211 def _get_diffset(self, source_repo_name, source_repo,
212 212 source_ref_id, target_ref_id,
213 213 target_commit, source_commit, diff_limit, file_limit,
214 214 fulldiff, hide_whitespace_changes, diff_context):
215 215
216 216 vcs_diff = PullRequestModel().get_diff(
217 217 source_repo, source_ref_id, target_ref_id,
218 218 hide_whitespace_changes, diff_context)
219 219
220 220 diff_processor = diffs.DiffProcessor(
221 221 vcs_diff, format='newdiff', diff_limit=diff_limit,
222 222 file_limit=file_limit, show_full_diff=fulldiff)
223 223
224 224 _parsed = diff_processor.prepare()
225 225
226 226 diffset = codeblocks.DiffSet(
227 227 repo_name=self.db_repo_name,
228 228 source_repo_name=source_repo_name,
229 229 source_node_getter=codeblocks.diffset_node_getter(target_commit),
230 230 target_node_getter=codeblocks.diffset_node_getter(source_commit),
231 231 )
232 232 diffset = self.path_filter.render_patchset_filtered(
233 233 diffset, _parsed, target_commit.raw_id, source_commit.raw_id)
234 234
235 235 return diffset
236 236
237 237 def _get_range_diffset(self, source_scm, source_repo,
238 238 commit1, commit2, diff_limit, file_limit,
239 239 fulldiff, hide_whitespace_changes, diff_context):
240 240 vcs_diff = source_scm.get_diff(
241 241 commit1, commit2,
242 242 ignore_whitespace=hide_whitespace_changes,
243 243 context=diff_context)
244 244
245 245 diff_processor = diffs.DiffProcessor(
246 246 vcs_diff, format='newdiff', diff_limit=diff_limit,
247 247 file_limit=file_limit, show_full_diff=fulldiff)
248 248
249 249 _parsed = diff_processor.prepare()
250 250
251 251 diffset = codeblocks.DiffSet(
252 252 repo_name=source_repo.repo_name,
253 253 source_node_getter=codeblocks.diffset_node_getter(commit1),
254 254 target_node_getter=codeblocks.diffset_node_getter(commit2))
255 255
256 256 diffset = self.path_filter.render_patchset_filtered(
257 257 diffset, _parsed, commit1.raw_id, commit2.raw_id)
258 258
259 259 return diffset
260 260
261 261 @LoginRequired()
262 262 @HasRepoPermissionAnyDecorator(
263 263 'repository.read', 'repository.write', 'repository.admin')
264 264 @view_config(
265 265 route_name='pullrequest_show', request_method='GET',
266 266 renderer='rhodecode:templates/pullrequests/pullrequest_show.mako')
267 267 def pull_request_show(self):
268 268 _ = self.request.translate
269 269 c = self.load_default_context()
270 270
271 271 pull_request = PullRequest.get_or_404(
272 272 self.request.matchdict['pull_request_id'])
273 273 pull_request_id = pull_request.pull_request_id
274 274
275 275 if pull_request.pull_request_state != PullRequest.STATE_CREATED:
276 276 log.debug('show: forbidden because pull request is in state %s',
277 277 pull_request.pull_request_state)
278 278 msg = _(u'Cannot show pull requests in state other than `{}`. '
279 279 u'Current state is: `{}`').format(PullRequest.STATE_CREATED,
280 280 pull_request.pull_request_state)
281 281 h.flash(msg, category='error')
282 282 raise HTTPFound(h.route_path('pullrequest_show_all',
283 283 repo_name=self.db_repo_name))
284 284
285 285 version = self.request.GET.get('version')
286 286 from_version = self.request.GET.get('from_version') or version
287 287 merge_checks = self.request.GET.get('merge_checks')
288 288 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
289 289
290 290 # fetch global flags of ignore ws or context lines
291 291 diff_context = diffs.get_diff_context(self.request)
292 292 hide_whitespace_changes = diffs.get_diff_whitespace_flag(self.request)
293 293
294 294 force_refresh = str2bool(self.request.GET.get('force_refresh'))
295 295
296 296 (pull_request_latest,
297 297 pull_request_at_ver,
298 298 pull_request_display_obj,
299 299 at_version) = PullRequestModel().get_pr_version(
300 300 pull_request_id, version=version)
301 301 pr_closed = pull_request_latest.is_closed()
302 302
303 303 if pr_closed and (version or from_version):
304 304 # not allow to browse versions
305 305 raise HTTPFound(h.route_path(
306 306 'pullrequest_show', repo_name=self.db_repo_name,
307 307 pull_request_id=pull_request_id))
308 308
309 309 versions = pull_request_display_obj.versions()
310 310 # used to store per-commit range diffs
311 311 c.changes = collections.OrderedDict()
312 312 c.range_diff_on = self.request.GET.get('range-diff') == "1"
313 313
314 314 c.at_version = at_version
315 315 c.at_version_num = (at_version
316 316 if at_version and at_version != 'latest'
317 317 else None)
318 318 c.at_version_pos = ChangesetComment.get_index_from_version(
319 319 c.at_version_num, versions)
320 320
321 321 (prev_pull_request_latest,
322 322 prev_pull_request_at_ver,
323 323 prev_pull_request_display_obj,
324 324 prev_at_version) = PullRequestModel().get_pr_version(
325 325 pull_request_id, version=from_version)
326 326
327 327 c.from_version = prev_at_version
328 328 c.from_version_num = (prev_at_version
329 329 if prev_at_version and prev_at_version != 'latest'
330 330 else None)
331 331 c.from_version_pos = ChangesetComment.get_index_from_version(
332 332 c.from_version_num, versions)
333 333
334 334 # define if we're in COMPARE mode or VIEW at version mode
335 335 compare = at_version != prev_at_version
336 336
337 337 # pull_requests repo_name we opened it against
338 338 # ie. target_repo must match
339 339 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
340 340 raise HTTPNotFound()
341 341
342 342 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
343 343 pull_request_at_ver)
344 344
345 345 c.pull_request = pull_request_display_obj
346 346 c.renderer = pull_request_at_ver.description_renderer or c.renderer
347 347 c.pull_request_latest = pull_request_latest
348 348
349 349 if compare or (at_version and not at_version == 'latest'):
350 350 c.allowed_to_change_status = False
351 351 c.allowed_to_update = False
352 352 c.allowed_to_merge = False
353 353 c.allowed_to_delete = False
354 354 c.allowed_to_comment = False
355 355 c.allowed_to_close = False
356 356 else:
357 357 can_change_status = PullRequestModel().check_user_change_status(
358 358 pull_request_at_ver, self._rhodecode_user)
359 359 c.allowed_to_change_status = can_change_status and not pr_closed
360 360
361 361 c.allowed_to_update = PullRequestModel().check_user_update(
362 362 pull_request_latest, self._rhodecode_user) and not pr_closed
363 363 c.allowed_to_merge = PullRequestModel().check_user_merge(
364 364 pull_request_latest, self._rhodecode_user) and not pr_closed
365 365 c.allowed_to_delete = PullRequestModel().check_user_delete(
366 366 pull_request_latest, self._rhodecode_user) and not pr_closed
367 367 c.allowed_to_comment = not pr_closed
368 368 c.allowed_to_close = c.allowed_to_merge and not pr_closed
369 369
370 370 c.forbid_adding_reviewers = False
371 371 c.forbid_author_to_review = False
372 372 c.forbid_commit_author_to_review = False
373 373
374 374 if pull_request_latest.reviewer_data and \
375 375 'rules' in pull_request_latest.reviewer_data:
376 376 rules = pull_request_latest.reviewer_data['rules'] or {}
377 377 try:
378 378 c.forbid_adding_reviewers = rules.get(
379 379 'forbid_adding_reviewers')
380 380 c.forbid_author_to_review = rules.get(
381 381 'forbid_author_to_review')
382 382 c.forbid_commit_author_to_review = rules.get(
383 383 'forbid_commit_author_to_review')
384 384 except Exception:
385 385 pass
386 386
387 387 # check merge capabilities
388 388 _merge_check = MergeCheck.validate(
389 389 pull_request_latest, auth_user=self._rhodecode_user,
390 390 translator=self.request.translate,
391 391 force_shadow_repo_refresh=force_refresh)
392 392 c.pr_merge_errors = _merge_check.error_details
393 393 c.pr_merge_possible = not _merge_check.failed
394 394 c.pr_merge_message = _merge_check.merge_msg
395 395
396 396 c.pr_merge_info = MergeCheck.get_merge_conditions(
397 397 pull_request_latest, translator=self.request.translate)
398 398
399 399 c.pull_request_review_status = _merge_check.review_status
400 400 if merge_checks:
401 401 self.request.override_renderer = \
402 402 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
403 403 return self._get_template_context(c)
404 404
405 405 comments_model = CommentsModel()
406 406
407 407 # reviewers and statuses
408 408 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
409 409 allowed_reviewers = [x[0].user_id for x in c.pull_request_reviewers]
410 410
411 411 # GENERAL COMMENTS with versions #
412 412 q = comments_model._all_general_comments_of_pull_request(pull_request_latest)
413 413 q = q.order_by(ChangesetComment.comment_id.asc())
414 414 general_comments = q
415 415
416 416 # pick comments we want to render at current version
417 417 c.comment_versions = comments_model.aggregate_comments(
418 418 general_comments, versions, c.at_version_num)
419 419 c.comments = c.comment_versions[c.at_version_num]['until']
420 420
421 421 # INLINE COMMENTS with versions #
422 422 q = comments_model._all_inline_comments_of_pull_request(pull_request_latest)
423 423 q = q.order_by(ChangesetComment.comment_id.asc())
424 424 inline_comments = q
425 425
426 426 c.inline_versions = comments_model.aggregate_comments(
427 427 inline_comments, versions, c.at_version_num, inline=True)
428 428
429 429 # TODOs
430 430 c.unresolved_comments = CommentsModel() \
431 431 .get_pull_request_unresolved_todos(pull_request)
432 432 c.resolved_comments = CommentsModel() \
433 433 .get_pull_request_resolved_todos(pull_request)
434 434
435 435 # inject latest version
436 436 latest_ver = PullRequest.get_pr_display_object(
437 437 pull_request_latest, pull_request_latest)
438 438
439 439 c.versions = versions + [latest_ver]
440 440
441 441 # if we use version, then do not show later comments
442 442 # than current version
443 443 display_inline_comments = collections.defaultdict(
444 444 lambda: collections.defaultdict(list))
445 445 for co in inline_comments:
446 446 if c.at_version_num:
447 447 # pick comments that are at least UPTO given version, so we
448 448 # don't render comments for higher version
449 449 should_render = co.pull_request_version_id and \
450 450 co.pull_request_version_id <= c.at_version_num
451 451 else:
452 452 # showing all, for 'latest'
453 453 should_render = True
454 454
455 455 if should_render:
456 456 display_inline_comments[co.f_path][co.line_no].append(co)
457 457
458 458 # load diff data into template context, if we use compare mode then
459 459 # diff is calculated based on changes between versions of PR
460 460
461 461 source_repo = pull_request_at_ver.source_repo
462 462 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
463 463
464 464 target_repo = pull_request_at_ver.target_repo
465 465 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
466 466
467 467 if compare:
468 468 # in compare switch the diff base to latest commit from prev version
469 469 target_ref_id = prev_pull_request_display_obj.revisions[0]
470 470
471 471 # despite opening commits for bookmarks/branches/tags, we always
472 472 # convert this to rev to prevent changes after bookmark or branch change
473 473 c.source_ref_type = 'rev'
474 474 c.source_ref = source_ref_id
475 475
476 476 c.target_ref_type = 'rev'
477 477 c.target_ref = target_ref_id
478 478
479 479 c.source_repo = source_repo
480 480 c.target_repo = target_repo
481 481
482 482 c.commit_ranges = []
483 483 source_commit = EmptyCommit()
484 484 target_commit = EmptyCommit()
485 485 c.missing_requirements = False
486 486
487 487 source_scm = source_repo.scm_instance()
488 488 target_scm = target_repo.scm_instance()
489 489
490 490 shadow_scm = None
491 491 try:
492 492 shadow_scm = pull_request_latest.get_shadow_repo()
493 493 except Exception:
494 494 log.debug('Failed to get shadow repo', exc_info=True)
495 495 # try first the existing source_repo, and then shadow
496 496 # repo if we can obtain one
497 497 commits_source_repo = source_scm or shadow_scm
498 498
499 499 c.commits_source_repo = commits_source_repo
500 500 c.ancestor = None # set it to None, to hide it from PR view
501 501
502 502 # empty version means latest, so we keep this to prevent
503 503 # double caching
504 504 version_normalized = version or 'latest'
505 505 from_version_normalized = from_version or 'latest'
506 506
507 507 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(target_repo)
508 508 cache_file_path = diff_cache_exist(
509 509 cache_path, 'pull_request', pull_request_id, version_normalized,
510 510 from_version_normalized, source_ref_id, target_ref_id,
511 511 hide_whitespace_changes, diff_context, c.fulldiff)
512 512
513 513 caching_enabled = self._is_diff_cache_enabled(c.target_repo)
514 514 force_recache = self.get_recache_flag()
515 515
516 516 cached_diff = None
517 517 if caching_enabled:
518 518 cached_diff = load_cached_diff(cache_file_path)
519 519
520 520 has_proper_commit_cache = (
521 521 cached_diff and cached_diff.get('commits')
522 522 and len(cached_diff.get('commits', [])) == 5
523 523 and cached_diff.get('commits')[0]
524 524 and cached_diff.get('commits')[3])
525 525
526 526 if not force_recache and not c.range_diff_on and has_proper_commit_cache:
527 527 diff_commit_cache = \
528 528 (ancestor_commit, commit_cache, missing_requirements,
529 529 source_commit, target_commit) = cached_diff['commits']
530 530 else:
531 531 diff_commit_cache = \
532 532 (ancestor_commit, commit_cache, missing_requirements,
533 533 source_commit, target_commit) = self.get_commits(
534 534 commits_source_repo,
535 535 pull_request_at_ver,
536 536 source_commit,
537 537 source_ref_id,
538 538 source_scm,
539 539 target_commit,
540 540 target_ref_id,
541 541 target_scm)
542 542
543 543 # register our commit range
544 544 for comm in commit_cache.values():
545 545 c.commit_ranges.append(comm)
546 546
547 547 c.missing_requirements = missing_requirements
548 548 c.ancestor_commit = ancestor_commit
549 549 c.statuses = source_repo.statuses(
550 550 [x.raw_id for x in c.commit_ranges])
551 551
552 552 # auto collapse if we have more than limit
553 553 collapse_limit = diffs.DiffProcessor._collapse_commits_over
554 554 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
555 555 c.compare_mode = compare
556 556
557 557 # diff_limit is the old behavior, will cut off the whole diff
558 558 # if the limit is applied otherwise will just hide the
559 559 # big files from the front-end
560 560 diff_limit = c.visual.cut_off_limit_diff
561 561 file_limit = c.visual.cut_off_limit_file
562 562
563 563 c.missing_commits = False
564 564 if (c.missing_requirements
565 565 or isinstance(source_commit, EmptyCommit)
566 566 or source_commit == target_commit):
567 567
568 568 c.missing_commits = True
569 569 else:
570 570 c.inline_comments = display_inline_comments
571 571
572 572 has_proper_diff_cache = cached_diff and cached_diff.get('commits')
573 573 if not force_recache and has_proper_diff_cache:
574 574 c.diffset = cached_diff['diff']
575 575 (ancestor_commit, commit_cache, missing_requirements,
576 576 source_commit, target_commit) = cached_diff['commits']
577 577 else:
578 578 c.diffset = self._get_diffset(
579 579 c.source_repo.repo_name, commits_source_repo,
580 580 source_ref_id, target_ref_id,
581 581 target_commit, source_commit,
582 582 diff_limit, file_limit, c.fulldiff,
583 583 hide_whitespace_changes, diff_context)
584 584
585 585 # save cached diff
586 586 if caching_enabled:
587 587 cache_diff(cache_file_path, c.diffset, diff_commit_cache)
588 588
589 589 c.limited_diff = c.diffset.limited_diff
590 590
591 591 # calculate removed files that are bound to comments
592 592 comment_deleted_files = [
593 593 fname for fname in display_inline_comments
594 594 if fname not in c.diffset.file_stats]
595 595
596 596 c.deleted_files_comments = collections.defaultdict(dict)
597 597 for fname, per_line_comments in display_inline_comments.items():
598 598 if fname in comment_deleted_files:
599 599 c.deleted_files_comments[fname]['stats'] = 0
600 600 c.deleted_files_comments[fname]['comments'] = list()
601 601 for lno, comments in per_line_comments.items():
602 602 c.deleted_files_comments[fname]['comments'].extend(comments)
603 603
604 604 # maybe calculate the range diff
605 605 if c.range_diff_on:
606 606 # TODO(marcink): set whitespace/context
607 607 context_lcl = 3
608 608 ign_whitespace_lcl = False
609 609
610 610 for commit in c.commit_ranges:
611 611 commit2 = commit
612 612 commit1 = commit.first_parent
613 613
614 614 range_diff_cache_file_path = diff_cache_exist(
615 615 cache_path, 'diff', commit.raw_id,
616 616 ign_whitespace_lcl, context_lcl, c.fulldiff)
617 617
618 618 cached_diff = None
619 619 if caching_enabled:
620 620 cached_diff = load_cached_diff(range_diff_cache_file_path)
621 621
622 622 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
623 623 if not force_recache and has_proper_diff_cache:
624 624 diffset = cached_diff['diff']
625 625 else:
626 626 diffset = self._get_range_diffset(
627 627 source_scm, source_repo,
628 628 commit1, commit2, diff_limit, file_limit,
629 629 c.fulldiff, ign_whitespace_lcl, context_lcl
630 630 )
631 631
632 632 # save cached diff
633 633 if caching_enabled:
634 634 cache_diff(range_diff_cache_file_path, diffset, None)
635 635
636 636 c.changes[commit.raw_id] = diffset
637 637
638 638 # this is a hack to properly display links, when creating PR, the
639 639 # compare view and others uses different notation, and
640 640 # compare_commits.mako renders links based on the target_repo.
641 641 # We need to swap that here to generate it properly on the html side
642 642 c.target_repo = c.source_repo
643 643
644 644 c.commit_statuses = ChangesetStatus.STATUSES
645 645
646 646 c.show_version_changes = not pr_closed
647 647 if c.show_version_changes:
648 648 cur_obj = pull_request_at_ver
649 649 prev_obj = prev_pull_request_at_ver
650 650
651 651 old_commit_ids = prev_obj.revisions
652 652 new_commit_ids = cur_obj.revisions
653 653 commit_changes = PullRequestModel()._calculate_commit_id_changes(
654 654 old_commit_ids, new_commit_ids)
655 655 c.commit_changes_summary = commit_changes
656 656
657 657 # calculate the diff for commits between versions
658 658 c.commit_changes = []
659 659 mark = lambda cs, fw: list(
660 660 h.itertools.izip_longest([], cs, fillvalue=fw))
661 661 for c_type, raw_id in mark(commit_changes.added, 'a') \
662 662 + mark(commit_changes.removed, 'r') \
663 663 + mark(commit_changes.common, 'c'):
664 664
665 665 if raw_id in commit_cache:
666 666 commit = commit_cache[raw_id]
667 667 else:
668 668 try:
669 669 commit = commits_source_repo.get_commit(raw_id)
670 670 except CommitDoesNotExistError:
671 671 # in case we fail extracting still use "dummy" commit
672 672 # for display in commit diff
673 673 commit = h.AttributeDict(
674 674 {'raw_id': raw_id,
675 675 'message': 'EMPTY or MISSING COMMIT'})
676 676 c.commit_changes.append([c_type, commit])
677 677
678 678 # current user review statuses for each version
679 679 c.review_versions = {}
680 680 if self._rhodecode_user.user_id in allowed_reviewers:
681 681 for co in general_comments:
682 682 if co.author.user_id == self._rhodecode_user.user_id:
683 683 status = co.status_change
684 684 if status:
685 685 _ver_pr = status[0].comment.pull_request_version_id
686 686 c.review_versions[_ver_pr] = status[0]
687 687
688 688 return self._get_template_context(c)
689 689
690 690 def get_commits(
691 691 self, commits_source_repo, pull_request_at_ver, source_commit,
692 692 source_ref_id, source_scm, target_commit, target_ref_id, target_scm):
693 693 commit_cache = collections.OrderedDict()
694 694 missing_requirements = False
695 695 try:
696 696 pre_load = ["author", "date", "message", "branch", "parents"]
697 697 show_revs = pull_request_at_ver.revisions
698 698 for rev in show_revs:
699 699 comm = commits_source_repo.get_commit(
700 700 commit_id=rev, pre_load=pre_load)
701 701 commit_cache[comm.raw_id] = comm
702 702
703 703 # Order here matters, we first need to get target, and then
704 704 # the source
705 705 target_commit = commits_source_repo.get_commit(
706 706 commit_id=safe_str(target_ref_id))
707 707
708 708 source_commit = commits_source_repo.get_commit(
709 709 commit_id=safe_str(source_ref_id))
710 710 except CommitDoesNotExistError:
711 711 log.warning(
712 712 'Failed to get commit from `{}` repo'.format(
713 713 commits_source_repo), exc_info=True)
714 714 except RepositoryRequirementError:
715 715 log.warning(
716 716 'Failed to get all required data from repo', exc_info=True)
717 717 missing_requirements = True
718 718 ancestor_commit = None
719 719 try:
720 720 ancestor_id = source_scm.get_common_ancestor(
721 721 source_commit.raw_id, target_commit.raw_id, target_scm)
722 722 ancestor_commit = source_scm.get_commit(ancestor_id)
723 723 except Exception:
724 724 ancestor_commit = None
725 725 return ancestor_commit, commit_cache, missing_requirements, source_commit, target_commit
726 726
727 727 def assure_not_empty_repo(self):
728 728 _ = self.request.translate
729 729
730 730 try:
731 731 self.db_repo.scm_instance().get_commit()
732 732 except EmptyRepositoryError:
733 733 h.flash(h.literal(_('There are no commits yet')),
734 734 category='warning')
735 735 raise HTTPFound(
736 736 h.route_path('repo_summary', repo_name=self.db_repo.repo_name))
737 737
738 738 @LoginRequired()
739 739 @NotAnonymous()
740 740 @HasRepoPermissionAnyDecorator(
741 741 'repository.read', 'repository.write', 'repository.admin')
742 742 @view_config(
743 743 route_name='pullrequest_new', request_method='GET',
744 744 renderer='rhodecode:templates/pullrequests/pullrequest.mako')
745 745 def pull_request_new(self):
746 746 _ = self.request.translate
747 747 c = self.load_default_context()
748 748
749 749 self.assure_not_empty_repo()
750 750 source_repo = self.db_repo
751 751
752 752 commit_id = self.request.GET.get('commit')
753 753 branch_ref = self.request.GET.get('branch')
754 754 bookmark_ref = self.request.GET.get('bookmark')
755 755
756 756 try:
757 757 source_repo_data = PullRequestModel().generate_repo_data(
758 758 source_repo, commit_id=commit_id,
759 759 branch=branch_ref, bookmark=bookmark_ref,
760 760 translator=self.request.translate)
761 761 except CommitDoesNotExistError as e:
762 762 log.exception(e)
763 763 h.flash(_('Commit does not exist'), 'error')
764 764 raise HTTPFound(
765 765 h.route_path('pullrequest_new', repo_name=source_repo.repo_name))
766 766
767 767 default_target_repo = source_repo
768 768
769 769 if source_repo.parent and c.has_origin_repo_read_perm:
770 770 parent_vcs_obj = source_repo.parent.scm_instance()
771 771 if parent_vcs_obj and not parent_vcs_obj.is_empty():
772 772 # change default if we have a parent repo
773 773 default_target_repo = source_repo.parent
774 774
775 775 target_repo_data = PullRequestModel().generate_repo_data(
776 776 default_target_repo, translator=self.request.translate)
777 777
778 778 selected_source_ref = source_repo_data['refs']['selected_ref']
779 779 title_source_ref = ''
780 780 if selected_source_ref:
781 781 title_source_ref = selected_source_ref.split(':', 2)[1]
782 782 c.default_title = PullRequestModel().generate_pullrequest_title(
783 783 source=source_repo.repo_name,
784 784 source_ref=title_source_ref,
785 785 target=default_target_repo.repo_name
786 786 )
787 787
788 788 c.default_repo_data = {
789 789 'source_repo_name': source_repo.repo_name,
790 790 'source_refs_json': json.dumps(source_repo_data),
791 791 'target_repo_name': default_target_repo.repo_name,
792 792 'target_refs_json': json.dumps(target_repo_data),
793 793 }
794 794 c.default_source_ref = selected_source_ref
795 795
796 796 return self._get_template_context(c)
797 797
798 798 @LoginRequired()
799 799 @NotAnonymous()
800 800 @HasRepoPermissionAnyDecorator(
801 801 'repository.read', 'repository.write', 'repository.admin')
802 802 @view_config(
803 803 route_name='pullrequest_repo_refs', request_method='GET',
804 804 renderer='json_ext', xhr=True)
805 805 def pull_request_repo_refs(self):
806 806 self.load_default_context()
807 807 target_repo_name = self.request.matchdict['target_repo_name']
808 808 repo = Repository.get_by_repo_name(target_repo_name)
809 809 if not repo:
810 810 raise HTTPNotFound()
811 811
812 812 target_perm = HasRepoPermissionAny(
813 813 'repository.read', 'repository.write', 'repository.admin')(
814 814 target_repo_name)
815 815 if not target_perm:
816 816 raise HTTPNotFound()
817 817
818 818 return PullRequestModel().generate_repo_data(
819 819 repo, translator=self.request.translate)
820 820
821 821 @LoginRequired()
822 822 @NotAnonymous()
823 823 @HasRepoPermissionAnyDecorator(
824 824 'repository.read', 'repository.write', 'repository.admin')
825 825 @view_config(
826 826 route_name='pullrequest_repo_targets', request_method='GET',
827 827 renderer='json_ext', xhr=True)
828 828 def pullrequest_repo_targets(self):
829 829 _ = self.request.translate
830 830 filter_query = self.request.GET.get('query')
831 831
832 832 # get the parents
833 833 parent_target_repos = []
834 834 if self.db_repo.parent:
835 835 parents_query = Repository.query() \
836 836 .order_by(func.length(Repository.repo_name)) \
837 837 .filter(Repository.fork_id == self.db_repo.parent.repo_id)
838 838
839 839 if filter_query:
840 840 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
841 841 parents_query = parents_query.filter(
842 842 Repository.repo_name.ilike(ilike_expression))
843 843 parents = parents_query.limit(20).all()
844 844
845 845 for parent in parents:
846 846 parent_vcs_obj = parent.scm_instance()
847 847 if parent_vcs_obj and not parent_vcs_obj.is_empty():
848 848 parent_target_repos.append(parent)
849 849
850 850 # get other forks, and repo itself
851 851 query = Repository.query() \
852 852 .order_by(func.length(Repository.repo_name)) \
853 853 .filter(
854 854 or_(Repository.repo_id == self.db_repo.repo_id, # repo itself
855 855 Repository.fork_id == self.db_repo.repo_id) # forks of this repo
856 856 ) \
857 857 .filter(~Repository.repo_id.in_([x.repo_id for x in parent_target_repos]))
858 858
859 859 if filter_query:
860 860 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
861 861 query = query.filter(Repository.repo_name.ilike(ilike_expression))
862 862
863 863 limit = max(20 - len(parent_target_repos), 5) # not less then 5
864 864 target_repos = query.limit(limit).all()
865 865
866 866 all_target_repos = target_repos + parent_target_repos
867 867
868 868 repos = []
869 869 # This checks permissions to the repositories
870 870 for obj in ScmModel().get_repos(all_target_repos):
871 871 repos.append({
872 872 'id': obj['name'],
873 873 'text': obj['name'],
874 874 'type': 'repo',
875 875 'repo_id': obj['dbrepo']['repo_id'],
876 876 'repo_type': obj['dbrepo']['repo_type'],
877 877 'private': obj['dbrepo']['private'],
878 878
879 879 })
880 880
881 881 data = {
882 882 'more': False,
883 883 'results': [{
884 884 'text': _('Repositories'),
885 885 'children': repos
886 886 }] if repos else []
887 887 }
888 888 return data
889 889
890 890 @LoginRequired()
891 891 @NotAnonymous()
892 892 @HasRepoPermissionAnyDecorator(
893 893 'repository.read', 'repository.write', 'repository.admin')
894 894 @CSRFRequired()
895 895 @view_config(
896 896 route_name='pullrequest_create', request_method='POST',
897 897 renderer=None)
898 898 def pull_request_create(self):
899 899 _ = self.request.translate
900 900 self.assure_not_empty_repo()
901 901 self.load_default_context()
902 902
903 903 controls = peppercorn.parse(self.request.POST.items())
904 904
905 905 try:
906 906 form = PullRequestForm(
907 907 self.request.translate, self.db_repo.repo_id)()
908 908 _form = form.to_python(controls)
909 909 except formencode.Invalid as errors:
910 910 if errors.error_dict.get('revisions'):
911 911 msg = 'Revisions: %s' % errors.error_dict['revisions']
912 912 elif errors.error_dict.get('pullrequest_title'):
913 913 msg = errors.error_dict.get('pullrequest_title')
914 914 else:
915 915 msg = _('Error creating pull request: {}').format(errors)
916 916 log.exception(msg)
917 917 h.flash(msg, 'error')
918 918
919 919 # would rather just go back to form ...
920 920 raise HTTPFound(
921 921 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
922 922
923 923 source_repo = _form['source_repo']
924 924 source_ref = _form['source_ref']
925 925 target_repo = _form['target_repo']
926 926 target_ref = _form['target_ref']
927 927 commit_ids = _form['revisions'][::-1]
928 928
929 929 # find the ancestor for this pr
930 930 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
931 931 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
932 932
933 933 if not (source_db_repo or target_db_repo):
934 934 h.flash(_('source_repo or target repo not found'), category='error')
935 935 raise HTTPFound(
936 936 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
937 937
938 938 # re-check permissions again here
939 939 # source_repo we must have read permissions
940 940
941 941 source_perm = HasRepoPermissionAny(
942 942 'repository.read', 'repository.write', 'repository.admin')(
943 943 source_db_repo.repo_name)
944 944 if not source_perm:
945 945 msg = _('Not Enough permissions to source repo `{}`.'.format(
946 946 source_db_repo.repo_name))
947 947 h.flash(msg, category='error')
948 948 # copy the args back to redirect
949 949 org_query = self.request.GET.mixed()
950 950 raise HTTPFound(
951 951 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
952 952 _query=org_query))
953 953
954 954 # target repo we must have read permissions, and also later on
955 955 # we want to check branch permissions here
956 956 target_perm = HasRepoPermissionAny(
957 957 'repository.read', 'repository.write', 'repository.admin')(
958 958 target_db_repo.repo_name)
959 959 if not target_perm:
960 960 msg = _('Not Enough permissions to target repo `{}`.'.format(
961 961 target_db_repo.repo_name))
962 962 h.flash(msg, category='error')
963 963 # copy the args back to redirect
964 964 org_query = self.request.GET.mixed()
965 965 raise HTTPFound(
966 966 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
967 967 _query=org_query))
968 968
969 969 source_scm = source_db_repo.scm_instance()
970 970 target_scm = target_db_repo.scm_instance()
971 971
972 972 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
973 973 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
974 974
975 975 ancestor = source_scm.get_common_ancestor(
976 976 source_commit.raw_id, target_commit.raw_id, target_scm)
977 977
978 978 # recalculate target ref based on ancestor
979 979 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
980 980 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
981 981
982 982 get_default_reviewers_data, validate_default_reviewers = \
983 983 PullRequestModel().get_reviewer_functions()
984 984
985 985 # recalculate reviewers logic, to make sure we can validate this
986 986 reviewer_rules = get_default_reviewers_data(
987 987 self._rhodecode_db_user, source_db_repo,
988 988 source_commit, target_db_repo, target_commit)
989 989
990 990 given_reviewers = _form['review_members']
991 991 reviewers = validate_default_reviewers(
992 992 given_reviewers, reviewer_rules)
993 993
994 994 pullrequest_title = _form['pullrequest_title']
995 995 title_source_ref = source_ref.split(':', 2)[1]
996 996 if not pullrequest_title:
997 997 pullrequest_title = PullRequestModel().generate_pullrequest_title(
998 998 source=source_repo,
999 999 source_ref=title_source_ref,
1000 1000 target=target_repo
1001 1001 )
1002 1002
1003 1003 description = _form['pullrequest_desc']
1004 1004 description_renderer = _form['description_renderer']
1005 1005
1006 1006 try:
1007 1007 pull_request = PullRequestModel().create(
1008 1008 created_by=self._rhodecode_user.user_id,
1009 1009 source_repo=source_repo,
1010 1010 source_ref=source_ref,
1011 1011 target_repo=target_repo,
1012 1012 target_ref=target_ref,
1013 1013 revisions=commit_ids,
1014 1014 reviewers=reviewers,
1015 1015 title=pullrequest_title,
1016 1016 description=description,
1017 1017 description_renderer=description_renderer,
1018 1018 reviewer_data=reviewer_rules,
1019 1019 auth_user=self._rhodecode_user
1020 1020 )
1021 1021 Session().commit()
1022 1022
1023 1023 h.flash(_('Successfully opened new pull request'),
1024 1024 category='success')
1025 1025 except Exception:
1026 1026 msg = _('Error occurred during creation of this pull request.')
1027 1027 log.exception(msg)
1028 1028 h.flash(msg, category='error')
1029 1029
1030 1030 # copy the args back to redirect
1031 1031 org_query = self.request.GET.mixed()
1032 1032 raise HTTPFound(
1033 1033 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1034 1034 _query=org_query))
1035 1035
1036 1036 raise HTTPFound(
1037 1037 h.route_path('pullrequest_show', repo_name=target_repo,
1038 1038 pull_request_id=pull_request.pull_request_id))
1039 1039
1040 1040 @LoginRequired()
1041 1041 @NotAnonymous()
1042 1042 @HasRepoPermissionAnyDecorator(
1043 1043 'repository.read', 'repository.write', 'repository.admin')
1044 1044 @CSRFRequired()
1045 1045 @view_config(
1046 1046 route_name='pullrequest_update', request_method='POST',
1047 1047 renderer='json_ext')
1048 1048 def pull_request_update(self):
1049 1049 pull_request = PullRequest.get_or_404(
1050 1050 self.request.matchdict['pull_request_id'])
1051 1051 _ = self.request.translate
1052 1052
1053 1053 self.load_default_context()
1054 redirect_url = None
1054 1055
1055 1056 if pull_request.is_closed():
1056 1057 log.debug('update: forbidden because pull request is closed')
1057 1058 msg = _(u'Cannot update closed pull requests.')
1058 1059 h.flash(msg, category='error')
1059 return True
1060 return {'response': True,
1061 'redirect_url': redirect_url}
1060 1062
1061 1063 if pull_request.pull_request_state != PullRequest.STATE_CREATED:
1062 1064 log.debug('update: forbidden because pull request is in state %s',
1063 1065 pull_request.pull_request_state)
1064 1066 msg = _(u'Cannot update pull requests in state other than `{}`. '
1065 1067 u'Current state is: `{}`').format(PullRequest.STATE_CREATED,
1066 1068 pull_request.pull_request_state)
1067 1069 h.flash(msg, category='error')
1068 return True
1070 return {'response': True,
1071 'redirect_url': redirect_url}
1069 1072
1070 1073 # only owner or admin can update it
1071 1074 allowed_to_update = PullRequestModel().check_user_update(
1072 1075 pull_request, self._rhodecode_user)
1073 1076 if allowed_to_update:
1074 1077 controls = peppercorn.parse(self.request.POST.items())
1078 force_refresh = str2bool(self.request.POST.get('force_refresh'))
1075 1079
1076 1080 if 'review_members' in controls:
1077 1081 self._update_reviewers(
1078 1082 pull_request, controls['review_members'],
1079 1083 pull_request.reviewer_data)
1080 1084 elif str2bool(self.request.POST.get('update_commits', 'false')):
1081 1085 self._update_commits(pull_request)
1086 if force_refresh:
1087 redirect_url = h.route_path(
1088 'pullrequest_show', repo_name=self.db_repo_name,
1089 pull_request_id=pull_request.pull_request_id,
1090 _query={"force_refresh": 1})
1082 1091 elif str2bool(self.request.POST.get('edit_pull_request', 'false')):
1083 1092 self._edit_pull_request(pull_request)
1084 1093 else:
1085 1094 raise HTTPBadRequest()
1086 return True
1095
1096 return {'response': True,
1097 'redirect_url': redirect_url}
1087 1098 raise HTTPForbidden()
1088 1099
1089 1100 def _edit_pull_request(self, pull_request):
1090 1101 _ = self.request.translate
1091 1102
1092 1103 try:
1093 1104 PullRequestModel().edit(
1094 1105 pull_request,
1095 1106 self.request.POST.get('title'),
1096 1107 self.request.POST.get('description'),
1097 1108 self.request.POST.get('description_renderer'),
1098 1109 self._rhodecode_user)
1099 1110 except ValueError:
1100 1111 msg = _(u'Cannot update closed pull requests.')
1101 1112 h.flash(msg, category='error')
1102 1113 return
1103 1114 else:
1104 1115 Session().commit()
1105 1116
1106 1117 msg = _(u'Pull request title & description updated.')
1107 1118 h.flash(msg, category='success')
1108 1119 return
1109 1120
1110 1121 def _update_commits(self, pull_request):
1111 1122 _ = self.request.translate
1112 1123
1113 1124 with pull_request.set_state(PullRequest.STATE_UPDATING):
1114 1125 resp = PullRequestModel().update_commits(pull_request)
1115 1126
1116 1127 if resp.executed:
1117 1128
1118 1129 if resp.target_changed and resp.source_changed:
1119 1130 changed = 'target and source repositories'
1120 1131 elif resp.target_changed and not resp.source_changed:
1121 1132 changed = 'target repository'
1122 1133 elif not resp.target_changed and resp.source_changed:
1123 1134 changed = 'source repository'
1124 1135 else:
1125 1136 changed = 'nothing'
1126 1137
1127 1138 msg = _(u'Pull request updated to "{source_commit_id}" with '
1128 1139 u'{count_added} added, {count_removed} removed commits. '
1129 1140 u'Source of changes: {change_source}')
1130 1141 msg = msg.format(
1131 1142 source_commit_id=pull_request.source_ref_parts.commit_id,
1132 1143 count_added=len(resp.changes.added),
1133 1144 count_removed=len(resp.changes.removed),
1134 1145 change_source=changed)
1135 1146 h.flash(msg, category='success')
1136 1147
1137 1148 channel = '/repo${}$/pr/{}'.format(
1138 1149 pull_request.target_repo.repo_name, pull_request.pull_request_id)
1139 1150 message = msg + (
1140 1151 ' - <a onclick="window.location.reload()">'
1141 1152 '<strong>{}</strong></a>'.format(_('Reload page')))
1142 1153 channelstream.post_message(
1143 1154 channel, message, self._rhodecode_user.username,
1144 1155 registry=self.request.registry)
1145 1156 else:
1146 1157 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
1147 1158 warning_reasons = [
1148 1159 UpdateFailureReason.NO_CHANGE,
1149 1160 UpdateFailureReason.WRONG_REF_TYPE,
1150 1161 ]
1151 1162 category = 'warning' if resp.reason in warning_reasons else 'error'
1152 1163 h.flash(msg, category=category)
1153 1164
1154 1165 @LoginRequired()
1155 1166 @NotAnonymous()
1156 1167 @HasRepoPermissionAnyDecorator(
1157 1168 'repository.read', 'repository.write', 'repository.admin')
1158 1169 @CSRFRequired()
1159 1170 @view_config(
1160 1171 route_name='pullrequest_merge', request_method='POST',
1161 1172 renderer='json_ext')
1162 1173 def pull_request_merge(self):
1163 1174 """
1164 1175 Merge will perform a server-side merge of the specified
1165 1176 pull request, if the pull request is approved and mergeable.
1166 1177 After successful merging, the pull request is automatically
1167 1178 closed, with a relevant comment.
1168 1179 """
1169 1180 pull_request = PullRequest.get_or_404(
1170 1181 self.request.matchdict['pull_request_id'])
1171 1182 _ = self.request.translate
1172 1183
1173 1184 if pull_request.pull_request_state != PullRequest.STATE_CREATED:
1174 1185 log.debug('show: forbidden because pull request is in state %s',
1175 1186 pull_request.pull_request_state)
1176 1187 msg = _(u'Cannot merge pull requests in state other than `{}`. '
1177 1188 u'Current state is: `{}`').format(PullRequest.STATE_CREATED,
1178 1189 pull_request.pull_request_state)
1179 1190 h.flash(msg, category='error')
1180 1191 raise HTTPFound(
1181 1192 h.route_path('pullrequest_show',
1182 1193 repo_name=pull_request.target_repo.repo_name,
1183 1194 pull_request_id=pull_request.pull_request_id))
1184 1195
1185 1196 self.load_default_context()
1186 1197
1187 1198 with pull_request.set_state(PullRequest.STATE_UPDATING):
1188 1199 check = MergeCheck.validate(
1189 1200 pull_request, auth_user=self._rhodecode_user,
1190 1201 translator=self.request.translate)
1191 1202 merge_possible = not check.failed
1192 1203
1193 1204 for err_type, error_msg in check.errors:
1194 1205 h.flash(error_msg, category=err_type)
1195 1206
1196 1207 if merge_possible:
1197 1208 log.debug("Pre-conditions checked, trying to merge.")
1198 1209 extras = vcs_operation_context(
1199 1210 self.request.environ, repo_name=pull_request.target_repo.repo_name,
1200 1211 username=self._rhodecode_db_user.username, action='push',
1201 1212 scm=pull_request.target_repo.repo_type)
1202 1213 with pull_request.set_state(PullRequest.STATE_UPDATING):
1203 1214 self._merge_pull_request(
1204 1215 pull_request, self._rhodecode_db_user, extras)
1205 1216 else:
1206 1217 log.debug("Pre-conditions failed, NOT merging.")
1207 1218
1208 1219 raise HTTPFound(
1209 1220 h.route_path('pullrequest_show',
1210 1221 repo_name=pull_request.target_repo.repo_name,
1211 1222 pull_request_id=pull_request.pull_request_id))
1212 1223
1213 1224 def _merge_pull_request(self, pull_request, user, extras):
1214 1225 _ = self.request.translate
1215 1226 merge_resp = PullRequestModel().merge_repo(pull_request, user, extras=extras)
1216 1227
1217 1228 if merge_resp.executed:
1218 1229 log.debug("The merge was successful, closing the pull request.")
1219 1230 PullRequestModel().close_pull_request(
1220 1231 pull_request.pull_request_id, user)
1221 1232 Session().commit()
1222 1233 msg = _('Pull request was successfully merged and closed.')
1223 1234 h.flash(msg, category='success')
1224 1235 else:
1225 1236 log.debug(
1226 1237 "The merge was not successful. Merge response: %s", merge_resp)
1227 1238 msg = merge_resp.merge_status_message
1228 1239 h.flash(msg, category='error')
1229 1240
1230 1241 def _update_reviewers(self, pull_request, review_members, reviewer_rules):
1231 1242 _ = self.request.translate
1232 1243
1233 1244 get_default_reviewers_data, validate_default_reviewers = \
1234 1245 PullRequestModel().get_reviewer_functions()
1235 1246
1236 1247 try:
1237 1248 reviewers = validate_default_reviewers(review_members, reviewer_rules)
1238 1249 except ValueError as e:
1239 1250 log.error('Reviewers Validation: {}'.format(e))
1240 1251 h.flash(e, category='error')
1241 1252 return
1242 1253
1243 1254 old_calculated_status = pull_request.calculated_review_status()
1244 1255 PullRequestModel().update_reviewers(
1245 1256 pull_request, reviewers, self._rhodecode_user)
1246 1257 h.flash(_('Pull request reviewers updated.'), category='success')
1247 1258 Session().commit()
1248 1259
1249 1260 # trigger status changed if change in reviewers changes the status
1250 1261 calculated_status = pull_request.calculated_review_status()
1251 1262 if old_calculated_status != calculated_status:
1252 1263 PullRequestModel().trigger_pull_request_hook(
1253 1264 pull_request, self._rhodecode_user, 'review_status_change',
1254 1265 data={'status': calculated_status})
1255 1266
1256 1267 @LoginRequired()
1257 1268 @NotAnonymous()
1258 1269 @HasRepoPermissionAnyDecorator(
1259 1270 'repository.read', 'repository.write', 'repository.admin')
1260 1271 @CSRFRequired()
1261 1272 @view_config(
1262 1273 route_name='pullrequest_delete', request_method='POST',
1263 1274 renderer='json_ext')
1264 1275 def pull_request_delete(self):
1265 1276 _ = self.request.translate
1266 1277
1267 1278 pull_request = PullRequest.get_or_404(
1268 1279 self.request.matchdict['pull_request_id'])
1269 1280 self.load_default_context()
1270 1281
1271 1282 pr_closed = pull_request.is_closed()
1272 1283 allowed_to_delete = PullRequestModel().check_user_delete(
1273 1284 pull_request, self._rhodecode_user) and not pr_closed
1274 1285
1275 1286 # only owner can delete it !
1276 1287 if allowed_to_delete:
1277 1288 PullRequestModel().delete(pull_request, self._rhodecode_user)
1278 1289 Session().commit()
1279 1290 h.flash(_('Successfully deleted pull request'),
1280 1291 category='success')
1281 1292 raise HTTPFound(h.route_path('pullrequest_show_all',
1282 1293 repo_name=self.db_repo_name))
1283 1294
1284 1295 log.warning('user %s tried to delete pull request without access',
1285 1296 self._rhodecode_user)
1286 1297 raise HTTPNotFound()
1287 1298
1288 1299 @LoginRequired()
1289 1300 @NotAnonymous()
1290 1301 @HasRepoPermissionAnyDecorator(
1291 1302 'repository.read', 'repository.write', 'repository.admin')
1292 1303 @CSRFRequired()
1293 1304 @view_config(
1294 1305 route_name='pullrequest_comment_create', request_method='POST',
1295 1306 renderer='json_ext')
1296 1307 def pull_request_comment_create(self):
1297 1308 _ = self.request.translate
1298 1309
1299 1310 pull_request = PullRequest.get_or_404(
1300 1311 self.request.matchdict['pull_request_id'])
1301 1312 pull_request_id = pull_request.pull_request_id
1302 1313
1303 1314 if pull_request.is_closed():
1304 1315 log.debug('comment: forbidden because pull request is closed')
1305 1316 raise HTTPForbidden()
1306 1317
1307 1318 allowed_to_comment = PullRequestModel().check_user_comment(
1308 1319 pull_request, self._rhodecode_user)
1309 1320 if not allowed_to_comment:
1310 1321 log.debug(
1311 1322 'comment: forbidden because pull request is from forbidden repo')
1312 1323 raise HTTPForbidden()
1313 1324
1314 1325 c = self.load_default_context()
1315 1326
1316 1327 status = self.request.POST.get('changeset_status', None)
1317 1328 text = self.request.POST.get('text')
1318 1329 comment_type = self.request.POST.get('comment_type')
1319 1330 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
1320 1331 close_pull_request = self.request.POST.get('close_pull_request')
1321 1332
1322 1333 # the logic here should work like following, if we submit close
1323 1334 # pr comment, use `close_pull_request_with_comment` function
1324 1335 # else handle regular comment logic
1325 1336
1326 1337 if close_pull_request:
1327 1338 # only owner or admin or person with write permissions
1328 1339 allowed_to_close = PullRequestModel().check_user_update(
1329 1340 pull_request, self._rhodecode_user)
1330 1341 if not allowed_to_close:
1331 1342 log.debug('comment: forbidden because not allowed to close '
1332 1343 'pull request %s', pull_request_id)
1333 1344 raise HTTPForbidden()
1334 1345
1335 1346 # This also triggers `review_status_change`
1336 1347 comment, status = PullRequestModel().close_pull_request_with_comment(
1337 1348 pull_request, self._rhodecode_user, self.db_repo, message=text,
1338 1349 auth_user=self._rhodecode_user)
1339 1350 Session().flush()
1340 1351
1341 1352 PullRequestModel().trigger_pull_request_hook(
1342 1353 pull_request, self._rhodecode_user, 'comment',
1343 1354 data={'comment': comment})
1344 1355
1345 1356 else:
1346 1357 # regular comment case, could be inline, or one with status.
1347 1358 # for that one we check also permissions
1348 1359
1349 1360 allowed_to_change_status = PullRequestModel().check_user_change_status(
1350 1361 pull_request, self._rhodecode_user)
1351 1362
1352 1363 if status and allowed_to_change_status:
1353 1364 message = (_('Status change %(transition_icon)s %(status)s')
1354 1365 % {'transition_icon': '>',
1355 1366 'status': ChangesetStatus.get_status_lbl(status)})
1356 1367 text = text or message
1357 1368
1358 1369 comment = CommentsModel().create(
1359 1370 text=text,
1360 1371 repo=self.db_repo.repo_id,
1361 1372 user=self._rhodecode_user.user_id,
1362 1373 pull_request=pull_request,
1363 1374 f_path=self.request.POST.get('f_path'),
1364 1375 line_no=self.request.POST.get('line'),
1365 1376 status_change=(ChangesetStatus.get_status_lbl(status)
1366 1377 if status and allowed_to_change_status else None),
1367 1378 status_change_type=(status
1368 1379 if status and allowed_to_change_status else None),
1369 1380 comment_type=comment_type,
1370 1381 resolves_comment_id=resolves_comment_id,
1371 1382 auth_user=self._rhodecode_user
1372 1383 )
1373 1384
1374 1385 if allowed_to_change_status:
1375 1386 # calculate old status before we change it
1376 1387 old_calculated_status = pull_request.calculated_review_status()
1377 1388
1378 1389 # get status if set !
1379 1390 if status:
1380 1391 ChangesetStatusModel().set_status(
1381 1392 self.db_repo.repo_id,
1382 1393 status,
1383 1394 self._rhodecode_user.user_id,
1384 1395 comment,
1385 1396 pull_request=pull_request
1386 1397 )
1387 1398
1388 1399 Session().flush()
1389 1400 # this is somehow required to get access to some relationship
1390 1401 # loaded on comment
1391 1402 Session().refresh(comment)
1392 1403
1393 1404 PullRequestModel().trigger_pull_request_hook(
1394 1405 pull_request, self._rhodecode_user, 'comment',
1395 1406 data={'comment': comment})
1396 1407
1397 1408 # we now calculate the status of pull request, and based on that
1398 1409 # calculation we set the commits status
1399 1410 calculated_status = pull_request.calculated_review_status()
1400 1411 if old_calculated_status != calculated_status:
1401 1412 PullRequestModel().trigger_pull_request_hook(
1402 1413 pull_request, self._rhodecode_user, 'review_status_change',
1403 1414 data={'status': calculated_status})
1404 1415
1405 1416 Session().commit()
1406 1417
1407 1418 data = {
1408 1419 'target_id': h.safeid(h.safe_unicode(
1409 1420 self.request.POST.get('f_path'))),
1410 1421 }
1411 1422 if comment:
1412 1423 c.co = comment
1413 1424 rendered_comment = render(
1414 1425 'rhodecode:templates/changeset/changeset_comment_block.mako',
1415 1426 self._get_template_context(c), self.request)
1416 1427
1417 1428 data.update(comment.get_dict())
1418 1429 data.update({'rendered_text': rendered_comment})
1419 1430
1420 1431 return data
1421 1432
1422 1433 @LoginRequired()
1423 1434 @NotAnonymous()
1424 1435 @HasRepoPermissionAnyDecorator(
1425 1436 'repository.read', 'repository.write', 'repository.admin')
1426 1437 @CSRFRequired()
1427 1438 @view_config(
1428 1439 route_name='pullrequest_comment_delete', request_method='POST',
1429 1440 renderer='json_ext')
1430 1441 def pull_request_comment_delete(self):
1431 1442 pull_request = PullRequest.get_or_404(
1432 1443 self.request.matchdict['pull_request_id'])
1433 1444
1434 1445 comment = ChangesetComment.get_or_404(
1435 1446 self.request.matchdict['comment_id'])
1436 1447 comment_id = comment.comment_id
1437 1448
1438 1449 if pull_request.is_closed():
1439 1450 log.debug('comment: forbidden because pull request is closed')
1440 1451 raise HTTPForbidden()
1441 1452
1442 1453 if not comment:
1443 1454 log.debug('Comment with id:%s not found, skipping', comment_id)
1444 1455 # comment already deleted in another call probably
1445 1456 return True
1446 1457
1447 1458 if comment.pull_request.is_closed():
1448 1459 # don't allow deleting comments on closed pull request
1449 1460 raise HTTPForbidden()
1450 1461
1451 1462 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1452 1463 super_admin = h.HasPermissionAny('hg.admin')()
1453 1464 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1454 1465 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1455 1466 comment_repo_admin = is_repo_admin and is_repo_comment
1456 1467
1457 1468 if super_admin or comment_owner or comment_repo_admin:
1458 1469 old_calculated_status = comment.pull_request.calculated_review_status()
1459 1470 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
1460 1471 Session().commit()
1461 1472 calculated_status = comment.pull_request.calculated_review_status()
1462 1473 if old_calculated_status != calculated_status:
1463 1474 PullRequestModel().trigger_pull_request_hook(
1464 1475 comment.pull_request, self._rhodecode_user, 'review_status_change',
1465 1476 data={'status': calculated_status})
1466 1477 return True
1467 1478 else:
1468 1479 log.warning('No permissions for user %s to delete comment_id: %s',
1469 1480 self._rhodecode_db_user, comment_id)
1470 1481 raise HTTPNotFound()
@@ -1,475 +1,523 b''
1 1
2 2
3 3 //BUTTONS
4 4 button,
5 5 .btn,
6 6 input[type="button"] {
7 7 -webkit-appearance: none;
8 8 display: inline-block;
9 9 margin: 0 @padding/3 0 0;
10 10 padding: @button-padding;
11 11 text-align: center;
12 12 font-size: @basefontsize;
13 13 line-height: 1em;
14 14 font-family: @text-light;
15 15 text-decoration: none;
16 16 text-shadow: none;
17 17 color: @grey2;
18 18 background-color: white;
19 19 background-image: none;
20 20 border: none;
21 21 .border ( @border-thickness-buttons, @grey5 );
22 22 .border-radius (@border-radius);
23 23 cursor: pointer;
24 24 white-space: nowrap;
25 25 -webkit-transition: background .3s,color .3s;
26 26 -moz-transition: background .3s,color .3s;
27 27 -o-transition: background .3s,color .3s;
28 28 transition: background .3s,color .3s;
29 29 box-shadow: @button-shadow;
30 30 -webkit-box-shadow: @button-shadow;
31 31
32 32
33 33
34 34 a {
35 35 display: block;
36 36 margin: 0;
37 37 padding: 0;
38 38 color: inherit;
39 39 text-decoration: none;
40 40
41 41 &:hover {
42 42 text-decoration: none;
43 43 }
44 44 }
45 45
46 46 &:focus,
47 47 &:active {
48 48 outline:none;
49 49 }
50 50 &:hover {
51 51 color: @rcdarkblue;
52 52 background-color: @white;
53 53 .border ( @border-thickness, @grey4 );
54 54 }
55 55
56 56 .icon-remove {
57 57 display: none;
58 58 }
59 59
60 60 //disabled buttons
61 61 //last; overrides any other styles
62 62 &:disabled {
63 63 opacity: .7;
64 64 cursor: auto;
65 65 background-color: white;
66 66 color: @grey4;
67 67 text-shadow: none;
68 68 }
69 69
70 70 &.no-margin {
71 71 margin: 0 0 0 0;
72 72 }
73 73
74 74 &.btn-active {
75 75 color: @rcdarkblue;
76 76 background-color: @white;
77 77 .border ( @border-thickness, @rcdarkblue );
78 78 }
79 79
80 80 }
81 81
82 82
83 83 .btn-default {
84 84 border: @border-thickness solid @grey5;
85 85 background-image: none;
86 86 color: @grey2;
87 87
88 88 a {
89 89 color: @grey2;
90 90 }
91 91
92 92 &:hover,
93 93 &.active {
94 94 color: @rcdarkblue;
95 95 background-color: @white;
96 96 .border ( @border-thickness, @grey4 );
97 97
98 98 a {
99 99 color: @grey2;
100 100 }
101 101 }
102 102 &:disabled {
103 103 .border ( @border-thickness-buttons, @grey5 );
104 104 background-color: transparent;
105 105 }
106 106 &.btn-active {
107 107 color: @rcdarkblue;
108 108 background-color: @white;
109 109 .border ( @border-thickness, @rcdarkblue );
110 110 }
111 111 }
112 112
113 113 .btn-primary,
114 114 .btn-small, /* TODO: anderson: remove .btn-small to not mix with the new btn-sm */
115 115 .btn-success {
116 116 .border ( @border-thickness, @rcblue );
117 117 background-color: @rcblue;
118 118 color: white;
119 119
120 120 a {
121 121 color: white;
122 122 }
123 123
124 124 &:hover,
125 125 &.active {
126 126 .border ( @border-thickness, @rcdarkblue );
127 127 color: white;
128 128 background-color: @rcdarkblue;
129 129
130 130 a {
131 131 color: white;
132 132 }
133 133 }
134 134 &:disabled {
135 135 background-color: @rcblue;
136 136 }
137 137 }
138 138
139 139 .btn-secondary {
140 140 &:extend(.btn-default);
141 141
142 142 background-color: white;
143 143
144 144 &:focus {
145 145 outline: 0;
146 146 }
147 147
148 148 &:hover {
149 149 &:extend(.btn-default:hover);
150 150 }
151 151
152 152 &.btn-link {
153 153 &:extend(.btn-link);
154 154 color: @rcblue;
155 155 }
156 156
157 157 &:disabled {
158 158 color: @rcblue;
159 159 background-color: white;
160 160 }
161 161 }
162 162
163 163 .btn-warning,
164 164 .btn-danger,
165 165 .revoke_perm,
166 166 .btn-x,
167 167 .form .action_button.btn-x {
168 168 .border ( @border-thickness, @alert2 );
169 169 background-color: white;
170 170 color: @alert2;
171 171
172 172 a {
173 173 color: @alert2;
174 174 }
175 175
176 176 &:hover,
177 177 &.active {
178 178 .border ( @border-thickness, @alert2 );
179 179 color: white;
180 180 background-color: @alert2;
181 181
182 182 a {
183 183 color: white;
184 184 }
185 185 }
186 186
187 187 i {
188 188 display:none;
189 189 }
190 190
191 191 &:disabled {
192 192 background-color: white;
193 193 color: @alert2;
194 194 }
195 195 }
196 196
197 197 .btn-approved-status {
198 198 .border ( @border-thickness, @alert1 );
199 199 background-color: white;
200 200 color: @alert1;
201 201
202 202 }
203 203
204 204 .btn-rejected-status {
205 205 .border ( @border-thickness, @alert2 );
206 206 background-color: white;
207 207 color: @alert2;
208 208 }
209 209
210 210 .btn-sm,
211 211 .btn-mini,
212 212 .field-sm .btn {
213 213 padding: @padding/3;
214 214 }
215 215
216 216 .btn-xs {
217 217 padding: @padding/4;
218 218 }
219 219
220 220 .btn-lg {
221 221 padding: @padding * 1.2;
222 222 }
223 223
224 224 .btn-group {
225 225 display: inline-block;
226 226 .btn {
227 227 float: left;
228 228 margin: 0 0 0 0;
229 229 // first item
230 230 &:first-of-type:not(:last-of-type) {
231 231 border-radius: @border-radius 0 0 @border-radius;
232 232
233 233 }
234 234 // middle elements
235 235 &:not(:first-of-type):not(:last-of-type) {
236 236 border-radius: 0;
237 237 border-left-width: 0;
238 238 border-right-width: 0;
239 239 }
240 240 // last item
241 241 &:last-of-type:not(:first-of-type) {
242 242 border-radius: 0 @border-radius @border-radius 0;
243 243 }
244 244
245 245 &:only-child {
246 246 border-radius: @border-radius;
247 247 }
248 248 }
249 249
250 250 }
251 251
252
253 .btn-group-actions {
254 position: relative;
255 z-index: 100;
256
257 &:not(.open) .btn-action-switcher-container {
258 display: none;
259 }
260 }
261
262
263 .btn-action-switcher-container{
264 position: absolute;
265 top: 30px;
266 left: 0px;
267 }
268
269 .btn-action-switcher {
270 display: block;
271 position: relative;
272 z-index: 300;
273 min-width: 240px;
274 max-width: 500px;
275 margin-top: 4px;
276 margin-bottom: 24px;
277 font-size: 14px;
278 font-weight: 400;
279 padding: 8px 0;
280 background-color: #fff;
281 border: 1px solid @grey4;
282 border-radius: 3px;
283 box-shadow: @dropdown-shadow;
284
285 li {
286 display: block;
287 text-align: left;
288 list-style: none;
289 padding: 5px 10px;
290 }
291
292 li .action-help-block {
293 font-size: 10px;
294 line-height: normal;
295 color: @grey4;
296 }
297
298 }
299
252 300 .btn-link {
253 301 background: transparent;
254 302 border: none;
255 303 padding: 0;
256 304 color: @rcblue;
257 305
258 306 &:hover {
259 307 background: transparent;
260 308 border: none;
261 309 color: @rcdarkblue;
262 310 }
263 311
264 312 //disabled buttons
265 313 //last; overrides any other styles
266 314 &:disabled {
267 315 opacity: .7;
268 316 cursor: auto;
269 317 background-color: white;
270 318 color: @grey4;
271 319 text-shadow: none;
272 320 }
273 321
274 322 // TODO: johbo: Check if we can avoid this, indicates that the structure
275 323 // is not yet good.
276 324 // lisa: The button CSS reflects the button HTML; both need a cleanup.
277 325 &.btn-danger {
278 326 color: @alert2;
279 327
280 328 &:hover {
281 329 color: darken(@alert2,30%);
282 330 }
283 331
284 332 &:disabled {
285 333 color: @alert2;
286 334 }
287 335 }
288 336 }
289 337
290 338 .btn-social {
291 339 &:extend(.btn-default);
292 340 margin: 5px 5px 5px 0px;
293 341 min-width: 160px;
294 342 }
295 343
296 344 // TODO: johbo: check these exceptions
297 345
298 346 .links {
299 347
300 348 .btn + .btn {
301 349 margin-top: @padding;
302 350 }
303 351 }
304 352
305 353
306 354 .action_button {
307 355 display:inline;
308 356 margin: 0;
309 357 padding: 0 1em 0 0;
310 358 font-size: inherit;
311 359 color: @rcblue;
312 360 border: none;
313 361 border-radius: 0;
314 362 background-color: transparent;
315 363
316 364 &.last-item {
317 365 border: none;
318 366 padding: 0 0 0 0;
319 367 }
320 368
321 369 &:last-child {
322 370 border: none;
323 371 padding: 0 0 0 0;
324 372 }
325 373
326 374 &:hover {
327 375 color: @rcdarkblue;
328 376 background-color: transparent;
329 377 border: none;
330 378 }
331 379 }
332 380 .grid_delete {
333 381 .action_button {
334 382 border: none;
335 383 }
336 384 }
337 385
338 386
339 387 // TODO: johbo: Form button tweaks, check if we can use the classes instead
340 388 input[type="submit"] {
341 389 &:extend(.btn-primary);
342 390
343 391 &:focus {
344 392 outline: 0;
345 393 }
346 394
347 395 &:hover {
348 396 &:extend(.btn-primary:hover);
349 397 }
350 398
351 399 &.btn-link {
352 400 &:extend(.btn-link);
353 401 color: @rcblue;
354 402
355 403 &:disabled {
356 404 color: @rcblue;
357 405 background-color: transparent;
358 406 }
359 407 }
360 408
361 409 &:disabled {
362 410 .border ( @border-thickness-buttons, @rcblue );
363 411 background-color: @rcblue;
364 412 color: white;
365 413 opacity: 0.5;
366 414 }
367 415 }
368 416
369 417 input[type="reset"] {
370 418 &:extend(.btn-default);
371 419
372 420 // TODO: johbo: Check if this tweak can be avoided.
373 421 background: transparent;
374 422
375 423 &:focus {
376 424 outline: 0;
377 425 }
378 426
379 427 &:hover {
380 428 &:extend(.btn-default:hover);
381 429 }
382 430
383 431 &.btn-link {
384 432 &:extend(.btn-link);
385 433 color: @rcblue;
386 434
387 435 &:disabled {
388 436 border: none;
389 437 }
390 438 }
391 439
392 440 &:disabled {
393 441 .border ( @border-thickness-buttons, @rcblue );
394 442 background-color: white;
395 443 color: @rcblue;
396 444 }
397 445 }
398 446
399 447 input[type="submit"],
400 448 input[type="reset"] {
401 449 &.btn-danger {
402 450 &:extend(.btn-danger);
403 451
404 452 &:focus {
405 453 outline: 0;
406 454 }
407 455
408 456 &:hover {
409 457 &:extend(.btn-danger:hover);
410 458 }
411 459
412 460 &.btn-link {
413 461 &:extend(.btn-link);
414 462 color: @alert2;
415 463
416 464 &:hover {
417 465 color: darken(@alert2,30%);
418 466 }
419 467 }
420 468
421 469 &:disabled {
422 470 color: @alert2;
423 471 background-color: white;
424 472 }
425 473 }
426 474 &.btn-danger-action {
427 475 .border ( @border-thickness, @alert2 );
428 476 background-color: @alert2;
429 477 color: white;
430 478
431 479 a {
432 480 color: white;
433 481 }
434 482
435 483 &:hover {
436 484 background-color: darken(@alert2,20%);
437 485 }
438 486
439 487 &.active {
440 488 .border ( @border-thickness, @alert2 );
441 489 color: white;
442 490 background-color: @alert2;
443 491
444 492 a {
445 493 color: white;
446 494 }
447 495 }
448 496
449 497 &:disabled {
450 498 background-color: white;
451 499 color: @alert2;
452 500 }
453 501 }
454 502 }
455 503
456 504
457 505 .button-links {
458 506 float: left;
459 507 display: inline;
460 508 margin: 0;
461 509 padding-left: 0;
462 510 list-style: none;
463 511 text-align: right;
464 512
465 513 li {
466 514
467 515
468 516 }
469 517
470 518 li.active {
471 519 background-color: @grey6;
472 520 .border ( @border-thickness, @grey4 );
473 521 }
474 522
475 523 }
@@ -1,552 +1,608 b''
1 1 // # Copyright (C) 2010-2019 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 $('#pr_submit').attr('disabled', 'disabled');
45 45 }
46 46 else if (checksMeet) {
47 47 $('#pr_submit').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 individual 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
159 159 if (data.rules.voting_groups !== undefined) {
160 160 $.each(data.rules.voting_groups, function(index, rule_data) {
161 161 self.$rulesList.append(
162 162 self.addRule(rule_data.text)
163 163 )
164 164 });
165 165 }
166 166
167 167 if (data.rules.use_code_authors_for_review) {
168 168 self.$rulesList.append(
169 169 self.addRule(
170 170 _gettext('Reviewers picked from source code changes.'))
171 171 )
172 172 }
173 173 if (data.rules.forbid_adding_reviewers) {
174 174 $('#add_reviewer_input').remove();
175 175 self.$rulesList.append(
176 176 self.addRule(
177 177 _gettext('Adding new reviewers is forbidden.'))
178 178 )
179 179 }
180 180 if (data.rules.forbid_author_to_review) {
181 181 self.forbidReviewUsers.push(data.rules_data.pr_author);
182 182 self.$rulesList.append(
183 183 self.addRule(
184 184 _gettext('Author is not allowed to be a reviewer.'))
185 185 )
186 186 }
187 187 if (data.rules.forbid_commit_author_to_review) {
188 188
189 189 if (data.rules_data.forbidden_users) {
190 190 $.each(data.rules_data.forbidden_users, function(index, member_data) {
191 191 self.forbidReviewUsers.push(member_data)
192 192 });
193 193
194 194 }
195 195
196 196 self.$rulesList.append(
197 197 self.addRule(
198 198 _gettext('Commit Authors are not allowed to be a reviewer.'))
199 199 )
200 200 }
201 201
202 202 return self.forbidReviewUsers
203 203 };
204 204
205 205 this.loadDefaultReviewers = function(sourceRepo, sourceRef, targetRepo, targetRef) {
206 206
207 207 if (self.currentRequest) {
208 208 // make sure we cleanup old running requests before triggering this
209 209 // again
210 210 self.currentRequest.abort();
211 211 }
212 212
213 213 $('.calculate-reviewers').show();
214 214 // reset reviewer members
215 215 self.$reviewMembers.empty();
216 216
217 217 prButtonLock(true, null, 'reviewers');
218 218 $('#user').hide(); // hide user autocomplete before load
219 219
220 220 if (sourceRef.length !== 3 || targetRef.length !== 3) {
221 221 // don't load defaults in case we're missing some refs...
222 222 $('.calculate-reviewers').hide();
223 223 return
224 224 }
225 225
226 226 var url = pyroutes.url('repo_default_reviewers_data',
227 227 {
228 228 'repo_name': templateContext.repo_name,
229 229 'source_repo': sourceRepo,
230 230 'source_ref': sourceRef[2],
231 231 'target_repo': targetRepo,
232 232 'target_ref': targetRef[2]
233 233 });
234 234
235 235 self.currentRequest = $.get(url)
236 236 .done(function(data) {
237 237 self.currentRequest = null;
238 238
239 239 // review rules
240 240 self.loadReviewRules(data);
241 241
242 242 for (var i = 0; i < data.reviewers.length; i++) {
243 243 var reviewer = data.reviewers[i];
244 244 self.addReviewMember(
245 245 reviewer, reviewer.reasons, reviewer.mandatory);
246 246 }
247 247 $('.calculate-reviewers').hide();
248 248 prButtonLock(false, null, 'reviewers');
249 249 $('#user').show(); // show user autocomplete after load
250 250 });
251 251 };
252 252
253 253 // check those, refactor
254 254 this.removeReviewMember = function(reviewer_id, mark_delete) {
255 255 var reviewer = $('#reviewer_{0}'.format(reviewer_id));
256 256
257 257 if(typeof(mark_delete) === undefined){
258 258 mark_delete = false;
259 259 }
260 260
261 261 if(mark_delete === true){
262 262 if (reviewer){
263 263 // now delete the input
264 264 $('#reviewer_{0} input'.format(reviewer_id)).remove();
265 265 // mark as to-delete
266 266 var obj = $('#reviewer_{0}_name'.format(reviewer_id));
267 267 obj.addClass('to-delete');
268 268 obj.css({"text-decoration":"line-through", "opacity": 0.5});
269 269 }
270 270 }
271 271 else{
272 272 $('#reviewer_{0}'.format(reviewer_id)).remove();
273 273 }
274 274 };
275 275 this.reviewMemberEntry = function() {
276 276
277 277 };
278 278 this.addReviewMember = function(reviewer_obj, reasons, mandatory) {
279 279 var members = self.$reviewMembers.get(0);
280 280 var id = reviewer_obj.user_id;
281 281 var username = reviewer_obj.username;
282 282
283 283 var reasons = reasons || [];
284 284 var mandatory = mandatory || false;
285 285
286 286 // register IDS to check if we don't have this ID already in
287 287 var currentIds = [];
288 288 var _els = self.$reviewMembers.find('li').toArray();
289 289 for (el in _els){
290 290 currentIds.push(_els[el].id)
291 291 }
292 292
293 293 var userAllowedReview = function(userId) {
294 294 var allowed = true;
295 295 $.each(self.forbidReviewUsers, function(index, member_data) {
296 296 if (parseInt(userId) === member_data['user_id']) {
297 297 allowed = false;
298 298 return false // breaks the loop
299 299 }
300 300 });
301 301 return allowed
302 302 };
303 303
304 304 var userAllowed = userAllowedReview(id);
305 305 if (!userAllowed){
306 306 alert(_gettext('User `{0}` not allowed to be a reviewer').format(username));
307 307 } else {
308 308 // only add if it's not there
309 309 var alreadyReviewer = currentIds.indexOf('reviewer_'+id) != -1;
310 310
311 311 if (alreadyReviewer) {
312 312 alert(_gettext('User `{0}` already in reviewers').format(username));
313 313 } else {
314 314 members.innerHTML += renderTemplate('reviewMemberEntry', {
315 315 'member': reviewer_obj,
316 316 'mandatory': mandatory,
317 317 'allowed_to_update': true,
318 318 'review_status': 'not_reviewed',
319 319 'review_status_label': _gettext('Not Reviewed'),
320 320 'reasons': reasons,
321 321 'create': true
322 322 });
323 323 tooltipActivate();
324 324 }
325 325 }
326 326
327 327 };
328 328
329 329 this.updateReviewers = function(repo_name, pull_request_id){
330 330 var postData = $('#reviewers input').serialize();
331 331 _updatePullRequest(repo_name, pull_request_id, postData);
332 332 };
333 333
334 334 };
335 335
336 336
337 337 var _updatePullRequest = function(repo_name, pull_request_id, postData) {
338 338 var url = pyroutes.url(
339 339 'pullrequest_update',
340 340 {"repo_name": repo_name, "pull_request_id": pull_request_id});
341 341 if (typeof postData === 'string' ) {
342 342 postData += '&csrf_token=' + CSRF_TOKEN;
343 343 } else {
344 344 postData.csrf_token = CSRF_TOKEN;
345 345 }
346
346 347 var success = function(o) {
347 window.location.reload();
348 var redirectUrl = o['redirect_url'];
349 if (redirectUrl !== undefined && redirectUrl !== null && redirectUrl !== '') {
350 window.location = redirectUrl;
351 } else {
352 window.location.reload();
353 }
348 354 };
355
349 356 ajaxPOST(url, postData, success);
350 357 };
351 358
352 359 /**
353 360 * PULL REQUEST update commits
354 361 */
355 var updateCommits = function(repo_name, pull_request_id) {
362 var updateCommits = function(repo_name, pull_request_id, force) {
356 363 var postData = {
357 'update_commits': true};
364 'update_commits': true
365 };
366 if (force !== undefined && force === true) {
367 postData['force_refresh'] = true
368 }
358 369 _updatePullRequest(repo_name, pull_request_id, postData);
359 370 };
360 371
361 372
362 373 /**
363 374 * PULL REQUEST edit info
364 375 */
365 376 var editPullRequest = function(repo_name, pull_request_id, title, description, renderer) {
366 377 var url = pyroutes.url(
367 378 'pullrequest_update',
368 379 {"repo_name": repo_name, "pull_request_id": pull_request_id});
369 380
370 381 var postData = {
371 382 'title': title,
372 383 'description': description,
373 384 'description_renderer': renderer,
374 385 'edit_pull_request': true,
375 386 'csrf_token': CSRF_TOKEN
376 387 };
377 388 var success = function(o) {
378 389 window.location.reload();
379 390 };
380 391 ajaxPOST(url, postData, success);
381 392 };
382 393
383 394
384 395 /**
385 396 * Reviewer autocomplete
386 397 */
387 398 var ReviewerAutoComplete = function(inputId) {
388 399 $(inputId).autocomplete({
389 400 serviceUrl: pyroutes.url('user_autocomplete_data'),
390 401 minChars:2,
391 402 maxHeight:400,
392 403 deferRequestBy: 300, //miliseconds
393 404 showNoSuggestionNotice: true,
394 405 tabDisabled: true,
395 406 autoSelectFirst: true,
396 407 params: { user_id: templateContext.rhodecode_user.user_id, user_groups:true, user_groups_expand:true, skip_default_user:true },
397 408 formatResult: autocompleteFormatResult,
398 409 lookupFilter: autocompleteFilterResult,
399 410 onSelect: function(element, data) {
400 411 var mandatory = false;
401 412 var reasons = [_gettext('added manually by "{0}"').format(templateContext.rhodecode_user.username)];
402 413
403 414 // add whole user groups
404 415 if (data.value_type == 'user_group') {
405 416 reasons.push(_gettext('member of "{0}"').format(data.value_display));
406 417
407 418 $.each(data.members, function(index, member_data) {
408 419 var reviewer = member_data;
409 420 reviewer['user_id'] = member_data['id'];
410 421 reviewer['gravatar_link'] = member_data['icon_link'];
411 422 reviewer['user_link'] = member_data['profile_link'];
412 423 reviewer['rules'] = [];
413 424 reviewersController.addReviewMember(reviewer, reasons, mandatory);
414 425 })
415 426 }
416 427 // add single user
417 428 else {
418 429 var reviewer = data;
419 430 reviewer['user_id'] = data['id'];
420 431 reviewer['gravatar_link'] = data['icon_link'];
421 432 reviewer['user_link'] = data['profile_link'];
422 433 reviewer['rules'] = [];
423 434 reviewersController.addReviewMember(reviewer, reasons, mandatory);
424 435 }
425 436
426 437 $(inputId).val('');
427 438 }
428 439 });
429 440 };
430 441
431 442
432 443 VersionController = function () {
433 444 var self = this;
434 445 this.$verSource = $('input[name=ver_source]');
435 446 this.$verTarget = $('input[name=ver_target]');
436 447 this.$showVersionDiff = $('#show-version-diff');
437 448
438 449 this.adjustRadioSelectors = function (curNode) {
439 450 var getVal = function (item) {
440 451 if (item == 'latest') {
441 452 return Number.MAX_SAFE_INTEGER
442 453 }
443 454 else {
444 455 return parseInt(item)
445 456 }
446 457 };
447 458
448 459 var curVal = getVal($(curNode).val());
449 460 var cleared = false;
450 461
451 462 $.each(self.$verSource, function (index, value) {
452 463 var elVal = getVal($(value).val());
453 464
454 465 if (elVal > curVal) {
455 466 if ($(value).is(':checked')) {
456 467 cleared = true;
457 468 }
458 469 $(value).attr('disabled', 'disabled');
459 470 $(value).removeAttr('checked');
460 471 $(value).css({'opacity': 0.1});
461 472 }
462 473 else {
463 474 $(value).css({'opacity': 1});
464 475 $(value).removeAttr('disabled');
465 476 }
466 477 });
467 478
468 479 if (cleared) {
469 480 // if we unchecked an active, set the next one to same loc.
470 481 $(this.$verSource).filter('[value={0}]'.format(
471 482 curVal)).attr('checked', 'checked');
472 483 }
473 484
474 485 self.setLockAction(false,
475 486 $(curNode).data('verPos'),
476 487 $(this.$verSource).filter(':checked').data('verPos')
477 488 );
478 489 };
479 490
480 491
481 492 this.attachVersionListener = function () {
482 493 self.$verTarget.change(function (e) {
483 494 self.adjustRadioSelectors(this)
484 495 });
485 496 self.$verSource.change(function (e) {
486 497 self.adjustRadioSelectors(self.$verTarget.filter(':checked'))
487 498 });
488 499 };
489 500
490 501 this.init = function () {
491 502
492 503 var curNode = self.$verTarget.filter(':checked');
493 504 self.adjustRadioSelectors(curNode);
494 505 self.setLockAction(true);
495 506 self.attachVersionListener();
496 507
497 508 };
498 509
499 510 this.setLockAction = function (state, selectedVersion, otherVersion) {
500 511 var $showVersionDiff = this.$showVersionDiff;
501 512
502 513 if (state) {
503 514 $showVersionDiff.attr('disabled', 'disabled');
504 515 $showVersionDiff.addClass('disabled');
505 516 $showVersionDiff.html($showVersionDiff.data('labelTextLocked'));
506 517 }
507 518 else {
508 519 $showVersionDiff.removeAttr('disabled');
509 520 $showVersionDiff.removeClass('disabled');
510 521
511 522 if (selectedVersion == otherVersion) {
512 523 $showVersionDiff.html($showVersionDiff.data('labelTextShow'));
513 524 } else {
514 525 $showVersionDiff.html($showVersionDiff.data('labelTextDiff'));
515 526 }
516 527 }
517 528
518 529 };
519 530
520 531 this.showVersionDiff = function () {
521 532 var target = self.$verTarget.filter(':checked');
522 533 var source = self.$verSource.filter(':checked');
523 534
524 535 if (target.val() && source.val()) {
525 536 var params = {
526 537 'pull_request_id': templateContext.pull_request_data.pull_request_id,
527 538 'repo_name': templateContext.repo_name,
528 539 'version': target.val(),
529 540 'from_version': source.val()
530 541 };
531 542 window.location = pyroutes.url('pullrequest_show', params)
532 543 }
533 544
534 545 return false;
535 546 };
536 547
537 548 this.toggleVersionView = function (elem) {
538 549
539 550 if (this.$showVersionDiff.is(':visible')) {
540 551 $('.version-pr').hide();
541 552 this.$showVersionDiff.hide();
542 553 $(elem).html($(elem).data('toggleOn'))
543 554 } else {
544 555 $('.version-pr').show();
545 556 this.$showVersionDiff.show();
546 557 $(elem).html($(elem).data('toggleOff'))
547 558 }
548 559
549 560 return false
550 561 }
551 562
563 };
564
565
566 UpdatePrController = function () {
567 var self = this;
568 this.$updateCommits = $('#update_commits');
569 this.$updateCommitsSwitcher = $('#update_commits_switcher');
570
571 this.lockUpdateButton = function (label) {
572 self.$updateCommits.attr('disabled', 'disabled');
573 self.$updateCommitsSwitcher.attr('disabled', 'disabled');
574
575 self.$updateCommits.addClass('disabled');
576 self.$updateCommitsSwitcher.addClass('disabled');
577
578 self.$updateCommits.removeClass('btn-primary');
579 self.$updateCommitsSwitcher.removeClass('btn-primary');
580
581 self.$updateCommits.text(_gettext(label));
582 };
583
584 this.isUpdateLocked = function () {
585 return self.$updateCommits.attr('disabled') !== undefined;
586 };
587
588 this.updateCommits = function (curNode) {
589 if (self.isUpdateLocked()) {
590 return
591 }
592 self.lockUpdateButton(_gettext('Updating...'));
593 updateCommits(
594 templateContext.repo_name,
595 templateContext.pull_request_data.pull_request_id);
596 };
597
598 this.forceUpdateCommits = function () {
599 if (self.isUpdateLocked()) {
600 return
601 }
602 self.lockUpdateButton(_gettext('Force updating...'));
603 var force = true;
604 updateCommits(
605 templateContext.repo_name,
606 templateContext.pull_request_data.pull_request_id, force);
607 };
552 608 }; No newline at end of file
@@ -1,84 +1,82 b''
1 1
2 2 <div class="pull-request-wrap">
3 3
4 4 % if c.pr_merge_possible:
5 5 <h2 class="merge-status">
6 6 <span class="merge-icon success"><i class="icon-ok"></i></span>
7 7 ${_('This pull request can be merged automatically.')}
8 8 </h2>
9 9 % else:
10 10 <h2 class="merge-status">
11 11 <span class="merge-icon warning"><i class="icon-false"></i></span>
12 12 ${_('Merge is not currently possible because of below failed checks.')}
13 13 </h2>
14 14 % endif
15 15
16 16 % if c.pr_merge_errors.items():
17 17 <ul>
18 18 % for pr_check_key, pr_check_details in c.pr_merge_errors.items():
19 19 <% pr_check_type = pr_check_details['error_type'] %>
20 20 <li>
21 21 <div class="merge-message ${pr_check_type}" data-role="merge-message">
22 22 <span style="white-space: pre-line">- ${pr_check_details['message']}</span>
23 23 % if pr_check_key == 'todo':
24 24 % for co in pr_check_details['details']:
25 25 <a class="permalink" href="#comment-${co.comment_id}" onclick="Rhodecode.comments.scrollToComment($('#comment-${co.comment_id}'), 0, ${h.json.dumps(co.outdated)})"> #${co.comment_id}</a>${'' if loop.last else ','}
26 26 % endfor
27 27 % endif
28 28 </div>
29 29 </li>
30 30 % endfor
31 31 </ul>
32 32 % endif
33 33
34 34 <div class="pull-request-merge-actions">
35 35 % if c.allowed_to_merge:
36 36 ## Merge info, show only if all errors are taken care of
37 37 % if not c.pr_merge_errors and c.pr_merge_info:
38 38 <div class="pull-request-merge-info">
39 39 <ul>
40 40 % for pr_merge_key, pr_merge_details in c.pr_merge_info.items():
41 41 <li>
42 42 - ${pr_merge_details['message']}
43 43 </li>
44 44 % endfor
45 45 </ul>
46 46 </div>
47 47 % endif
48 48
49 49 <div>
50 50 ${h.secure_form(h.route_path('pullrequest_merge', repo_name=c.repo_name, pull_request_id=c.pull_request.pull_request_id), id='merge_pull_request_form', request=request)}
51 51 <% merge_disabled = ' disabled' if c.pr_merge_possible is False else '' %>
52 52
53 53 % if c.allowed_to_close:
54 54 ## close PR action, injected later next to COMMENT button
55 55 % if c.pull_request_review_status == c.REVIEW_STATUS_APPROVED:
56 56 <a id="close-pull-request-action" class="btn btn-approved-status" href="#close-as-approved" onclick="closePullRequest('${c.REVIEW_STATUS_APPROVED}'); return false;">
57 57 ${_('Close with status {}').format(h.commit_status_lbl(c.REVIEW_STATUS_APPROVED))}
58 58 </a>
59 59 % else:
60 60 <a id="close-pull-request-action" class="btn btn-rejected-status" href="#close-as-rejected" onclick="closePullRequest('${c.REVIEW_STATUS_REJECTED}'); return false;">
61 61 ${_('Close with status {}').format(h.commit_status_lbl(c.REVIEW_STATUS_REJECTED))}
62 62 </a>
63 63 % endif
64 64 % endif
65 65
66 66 <input type="submit" id="merge_pull_request" value="${_('Merge and close Pull Request')}" class="btn${merge_disabled}"${merge_disabled}>
67 67 ${h.end_form()}
68 68
69 69 <div class="pull-request-merge-refresh">
70 70 <a href="#refreshChecks" onclick="refreshMergeChecks(); return false;">${_('refresh checks')}</a>
71 /
72 <a class="tooltip" title="Force refresh of the merge workspace in case current status seems wrong." href="${h.route_path('pullrequest_show', repo_name=c.repo_name, pull_request_id=c.pull_request.pull_request_id,_query={"force_refresh":1})}">forced recheck</a>
73 71 </div>
74 72
75 73 </div>
76 74 % elif c.rhodecode_user.username != h.DEFAULT_USER:
77 75 <a class="btn" href="#" onclick="refreshMergeChecks(); return false;">${_('refresh checks')}</a>
78 76 <input type="submit" value="${_('Merge and close Pull Request')}" class="btn disabled" disabled="disabled" title="${_('You are not allowed to merge this pull request.')}">
79 77 % else:
80 78 <input type="submit" value="${_('Login to Merge this Pull Request')}" class="btn disabled" disabled="disabled">
81 79 % endif
82 80 </div>
83 81
84 82 </div>
@@ -1,794 +1,809 b''
1 1 <%inherit file="/base/base.mako"/>
2 2 <%namespace name="base" file="/base/base.mako"/>
3 3 <%namespace name="dt" file="/data_table/_dt_elements.mako"/>
4 4
5 5 <%def name="title()">
6 6 ${_('{} Pull Request !{}').format(c.repo_name, c.pull_request.pull_request_id)}
7 7 %if c.rhodecode_name:
8 8 &middot; ${h.branding(c.rhodecode_name)}
9 9 %endif
10 10 </%def>
11 11
12 12 <%def name="breadcrumbs_links()">
13 13 <span id="pr-title">
14 14 ${c.pull_request.title}
15 15 %if c.pull_request.is_closed():
16 16 (${_('Closed')})
17 17 %endif
18 18 </span>
19 19 <div id="pr-title-edit" class="input" style="display: none;">
20 20 ${h.text('pullrequest_title', id_="pr-title-input", class_="large", value=c.pull_request.title)}
21 21 </div>
22 22 </%def>
23 23
24 24 <%def name="menu_bar_nav()">
25 25 ${self.menu_items(active='repositories')}
26 26 </%def>
27 27
28 28 <%def name="menu_bar_subnav()">
29 29 ${self.repo_menu(active='showpullrequest')}
30 30 </%def>
31 31
32 32 <%def name="main()">
33 33
34 34 <script type="text/javascript">
35 35 // TODO: marcink switch this to pyroutes
36 36 AJAX_COMMENT_DELETE_URL = "${h.route_path('pullrequest_comment_delete',repo_name=c.repo_name,pull_request_id=c.pull_request.pull_request_id,comment_id='__COMMENT_ID__')}";
37 37 templateContext.pull_request_data.pull_request_id = ${c.pull_request.pull_request_id};
38 38 </script>
39 39 <div class="box">
40 40
41 41 ${self.breadcrumbs()}
42 42
43 43 <div class="box pr-summary">
44 44
45 45 <div class="summary-details block-left">
46 46 <% summary = lambda n:{False:'summary-short'}.get(n) %>
47 47 <div class="pr-details-title">
48 48 <a href="${h.route_path('pull_requests_global', pull_request_id=c.pull_request.pull_request_id)}">${_('Pull request !{}').format(c.pull_request.pull_request_id)}</a> ${_('From')} ${h.format_date(c.pull_request.created_on)}
49 49 %if c.allowed_to_update:
50 50 <div id="delete_pullrequest" class="pull-right action_button ${'' if c.allowed_to_delete else 'disabled' }" style="clear:inherit;padding: 0">
51 51 % if c.allowed_to_delete:
52 52 ${h.secure_form(h.route_path('pullrequest_delete', repo_name=c.pull_request.target_repo.repo_name, pull_request_id=c.pull_request.pull_request_id), request=request)}
53 53 ${h.submit('remove_%s' % c.pull_request.pull_request_id, _('Delete'),
54 54 class_="btn btn-link btn-danger no-margin",onclick="return confirm('"+_('Confirm to delete this pull request')+"');")}
55 55 ${h.end_form()}
56 56 % else:
57 57 ${_('Delete')}
58 58 % endif
59 59 </div>
60 60 <div id="open_edit_pullrequest" class="pull-right action_button">${_('Edit')}</div>
61 61 <div id="close_edit_pullrequest" class="pull-right action_button" style="display: none;padding: 0">${_('Cancel')}</div>
62 62 %endif
63 63 </div>
64 64
65 65 <div id="summary" class="fields pr-details-content">
66 66 <div class="field">
67 67 <div class="label-summary">
68 68 <label>${_('Source')}:</label>
69 69 </div>
70 70 <div class="input">
71 71 <div class="pr-origininfo">
72 72 ## branch link is only valid if it is a branch
73 73 <span class="tag">
74 74 %if c.pull_request.source_ref_parts.type == 'branch':
75 75 <a href="${h.route_path('repo_commits', repo_name=c.pull_request.source_repo.repo_name, _query=dict(branch=c.pull_request.source_ref_parts.name))}">${c.pull_request.source_ref_parts.type}: ${c.pull_request.source_ref_parts.name}</a>
76 76 %else:
77 77 ${c.pull_request.source_ref_parts.type}: ${c.pull_request.source_ref_parts.name}
78 78 %endif
79 79 </span>
80 80 <span class="clone-url">
81 81 <a href="${h.route_path('repo_summary', repo_name=c.pull_request.source_repo.repo_name)}">${c.pull_request.source_repo.clone_url()}</a>
82 82 </span>
83 83 <br/>
84 84 % if c.ancestor_commit:
85 85 ${_('Common ancestor')}:
86 86 <code><a href="${h.route_path('repo_commit', repo_name=c.target_repo.repo_name, commit_id=c.ancestor_commit.raw_id)}">${h.show_id(c.ancestor_commit)}</a></code>
87 87 % endif
88 88 </div>
89 89 %if h.is_hg(c.pull_request.source_repo):
90 90 <% clone_url = 'hg pull -r {} {}'.format(h.short_id(c.source_ref), c.pull_request.source_repo.clone_url()) %>
91 91 %elif h.is_git(c.pull_request.source_repo):
92 92 <% clone_url = 'git pull {} {}'.format(c.pull_request.source_repo.clone_url(), c.pull_request.source_ref_parts.name) %>
93 93 %endif
94 94
95 95 <div class="">
96 96 <input type="text" class="input-monospace pr-pullinfo" value="${clone_url}" readonly="readonly">
97 97 <i class="tooltip icon-clipboard clipboard-action pull-right pr-pullinfo-copy" data-clipboard-text="${clone_url}" title="${_('Copy the pull url')}"></i>
98 98 </div>
99 99
100 100 </div>
101 101 </div>
102 102 <div class="field">
103 103 <div class="label-summary">
104 104 <label>${_('Target')}:</label>
105 105 </div>
106 106 <div class="input">
107 107 <div class="pr-targetinfo">
108 108 ## branch link is only valid if it is a branch
109 109 <span class="tag">
110 110 %if c.pull_request.target_ref_parts.type == 'branch':
111 111 <a href="${h.route_path('repo_commits', repo_name=c.pull_request.target_repo.repo_name, _query=dict(branch=c.pull_request.target_ref_parts.name))}">${c.pull_request.target_ref_parts.type}: ${c.pull_request.target_ref_parts.name}</a>
112 112 %else:
113 113 ${c.pull_request.target_ref_parts.type}: ${c.pull_request.target_ref_parts.name}
114 114 %endif
115 115 </span>
116 116 <span class="clone-url">
117 117 <a href="${h.route_path('repo_summary', repo_name=c.pull_request.target_repo.repo_name)}">${c.pull_request.target_repo.clone_url()}</a>
118 118 </span>
119 119 </div>
120 120 </div>
121 121 </div>
122 122
123 123 ## Link to the shadow repository.
124 124 <div class="field">
125 125 <div class="label-summary">
126 126 <label>${_('Merge')}:</label>
127 127 </div>
128 128 <div class="input">
129 129 % if not c.pull_request.is_closed() and c.pull_request.shadow_merge_ref:
130 130 %if h.is_hg(c.pull_request.target_repo):
131 131 <% clone_url = 'hg clone --update {} {} pull-request-{}'.format(c.pull_request.shadow_merge_ref.name, c.shadow_clone_url, c.pull_request.pull_request_id) %>
132 132 %elif h.is_git(c.pull_request.target_repo):
133 133 <% clone_url = 'git clone --branch {} {} pull-request-{}'.format(c.pull_request.shadow_merge_ref.name, c.shadow_clone_url, c.pull_request.pull_request_id) %>
134 134 %endif
135 135 <div class="">
136 136 <input type="text" class="input-monospace pr-mergeinfo" value="${clone_url}" readonly="readonly">
137 137 <i class="tooltip icon-clipboard clipboard-action pull-right pr-mergeinfo-copy" data-clipboard-text="${clone_url}" title="${_('Copy the clone url')}"></i>
138 138 </div>
139 139 % else:
140 140 <div class="">
141 141 ${_('Shadow repository data not available')}.
142 142 </div>
143 143 % endif
144 144 </div>
145 145 </div>
146 146
147 147 <div class="field">
148 148 <div class="label-summary">
149 149 <label>${_('Review')}:</label>
150 150 </div>
151 151 <div class="input">
152 152 %if c.pull_request_review_status:
153 153 <i class="icon-circle review-status-${c.pull_request_review_status}"></i>
154 154 <span class="changeset-status-lbl tooltip">
155 155 %if c.pull_request.is_closed():
156 156 ${_('Closed')},
157 157 %endif
158 158 ${h.commit_status_lbl(c.pull_request_review_status)}
159 159 </span>
160 160 - ${_ungettext('calculated based on %s reviewer vote', 'calculated based on %s reviewers votes', len(c.pull_request_reviewers)) % len(c.pull_request_reviewers)}
161 161 %endif
162 162 </div>
163 163 </div>
164 164 <div class="field">
165 165 <div class="pr-description-label label-summary" title="${_('Rendered using {} renderer').format(c.renderer)}">
166 166 <label>${_('Description')}:</label>
167 167 </div>
168 168 <div id="pr-desc" class="input">
169 169 <div class="pr-description">${h.render(c.pull_request.description, renderer=c.renderer, repo_name=c.repo_name)}</div>
170 170 </div>
171 171 <div id="pr-desc-edit" class="input textarea editor" style="display: none;">
172 172 <input id="pr-renderer-input" type="hidden" name="description_renderer" value="${c.visual.default_renderer}">
173 173 ${dt.markup_form('pr-description-input', form_text=c.pull_request.description)}
174 174 </div>
175 175 </div>
176 176
177 177 <div class="field">
178 178 <div class="label-summary">
179 179 <label>${_('Versions')}:</label>
180 180 </div>
181 181
182 182 <% outdated_comm_count_ver = len(c.inline_versions[None]['outdated']) %>
183 183 <% general_outdated_comm_count_ver = len(c.comment_versions[None]['outdated']) %>
184 184
185 185 <div class="pr-versions">
186 186 % if c.show_version_changes:
187 187 <% outdated_comm_count_ver = len(c.inline_versions[c.at_version_num]['outdated']) %>
188 188 <% general_outdated_comm_count_ver = len(c.comment_versions[c.at_version_num]['outdated']) %>
189 189 <a id="show-pr-versions" class="input" onclick="return versionController.toggleVersionView(this)" href="#show-pr-versions"
190 190 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))}"
191 191 data-toggle-off="${_('Hide all versions of this pull request')}">
192 192 ${_ungettext('{} version available for this pull request, show it.', '{} versions available for this pull request, show them.', len(c.versions)).format(len(c.versions))}
193 193 </a>
194 194 <table>
195 195 ## SHOW ALL VERSIONS OF PR
196 196 <% ver_pr = None %>
197 197
198 198 % for data in reversed(list(enumerate(c.versions, 1))):
199 199 <% ver_pos = data[0] %>
200 200 <% ver = data[1] %>
201 201 <% ver_pr = ver.pull_request_version_id %>
202 202 <% display_row = '' if c.at_version and (c.at_version_num == ver_pr or c.from_version_num == ver_pr) else 'none' %>
203 203
204 204 <tr class="version-pr" style="display: ${display_row}">
205 205 <td>
206 206 <code>
207 207 <a href="${request.current_route_path(_query=dict(version=ver_pr or 'latest'))}">v${ver_pos}</a>
208 208 </code>
209 209 </td>
210 210 <td>
211 211 <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}"/>
212 212 <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}"/>
213 213 </td>
214 214 <td>
215 215 <% review_status = c.review_versions[ver_pr].status if ver_pr in c.review_versions else 'not_reviewed' %>
216 216 <i class="tooltip icon-circle review-status-${review_status}" title="${_('Your review status at this version')}"></i>
217 217 </div>
218 218 </td>
219 219 <td>
220 220 % if c.at_version_num != ver_pr:
221 221 <i class="icon-comment"></i>
222 222 <code class="tooltip" title="${_('Comment from pull request version v{0}, general:{1} inline:{2}').format(ver_pos, len(c.comment_versions[ver_pr]['at']), len(c.inline_versions[ver_pr]['at']))}">
223 223 G:${len(c.comment_versions[ver_pr]['at'])} / I:${len(c.inline_versions[ver_pr]['at'])}
224 224 </code>
225 225 % endif
226 226 </td>
227 227 <td>
228 228 ##<code>${ver.source_ref_parts.commit_id[:6]}</code>
229 229 </td>
230 230 <td>
231 231 ${h.age_component(ver.updated_on, time_is_local=True)}
232 232 </td>
233 233 </tr>
234 234 % endfor
235 235
236 236 <tr>
237 237 <td colspan="6">
238 238 <button id="show-version-diff" onclick="return versionController.showVersionDiff()" class="btn btn-sm" style="display: none"
239 239 data-label-text-locked="${_('select versions to show changes')}"
240 240 data-label-text-diff="${_('show changes between versions')}"
241 241 data-label-text-show="${_('show pull request for this version')}"
242 242 >
243 243 ${_('select versions to show changes')}
244 244 </button>
245 245 </td>
246 246 </tr>
247 247 </table>
248 248 % else:
249 249 <div class="input">
250 250 ${_('Pull request versions not available')}.
251 251 </div>
252 252 % endif
253 253 </div>
254 254 </div>
255 255
256 256 <div id="pr-save" class="field" style="display: none;">
257 257 <div class="label-summary"></div>
258 258 <div class="input">
259 259 <span id="edit_pull_request" class="btn btn-small no-margin">${_('Save Changes')}</span>
260 260 </div>
261 261 </div>
262 262 </div>
263 263 </div>
264 264 <div>
265 265 ## AUTHOR
266 266 <div class="reviewers-title block-right">
267 267 <div class="pr-details-title">
268 268 ${_('Author of this pull request')}
269 269 </div>
270 270 </div>
271 271 <div class="block-right pr-details-content reviewers">
272 272 <ul class="group_members">
273 273 <li>
274 274 ${self.gravatar_with_user(c.pull_request.author.email, 16, tooltip=True)}
275 275 </li>
276 276 </ul>
277 277 </div>
278 278
279 279 ## REVIEW RULES
280 280 <div id="review_rules" style="display: none" class="reviewers-title block-right">
281 281 <div class="pr-details-title">
282 282 ${_('Reviewer rules')}
283 283 %if c.allowed_to_update:
284 284 <span id="close_edit_reviewers" class="block-right action_button last-item" style="display: none;">${_('Close')}</span>
285 285 %endif
286 286 </div>
287 287 <div class="pr-reviewer-rules">
288 288 ## review rules will be appended here, by default reviewers logic
289 289 </div>
290 290 <input id="review_data" type="hidden" name="review_data" value="">
291 291 </div>
292 292
293 293 ## REVIEWERS
294 294 <div class="reviewers-title block-right">
295 295 <div class="pr-details-title">
296 296 ${_('Pull request reviewers')}
297 297 %if c.allowed_to_update:
298 298 <span id="open_edit_reviewers" class="block-right action_button last-item">${_('Edit')}</span>
299 299 %endif
300 300 </div>
301 301 </div>
302 302 <div id="reviewers" class="block-right pr-details-content reviewers">
303 303
304 304 ## members redering block
305 305 <input type="hidden" name="__start__" value="review_members:sequence">
306 306 <ul id="review_members" class="group_members">
307 307
308 308 % for review_obj, member, reasons, mandatory, status in c.pull_request_reviewers:
309 309 <script>
310 310 var member = ${h.json.dumps(h.reviewer_as_json(member, reasons=reasons, mandatory=mandatory, user_group=review_obj.rule_user_group_data()))|n};
311 311 var status = "${(status[0][1].status if status else 'not_reviewed')}";
312 312 var status_lbl = "${h.commit_status_lbl(status[0][1].status if status else 'not_reviewed')}";
313 313 var allowed_to_update = ${h.json.dumps(c.allowed_to_update)};
314 314
315 315 var entry = renderTemplate('reviewMemberEntry', {
316 316 'member': member,
317 317 'mandatory': member.mandatory,
318 318 'reasons': member.reasons,
319 319 'allowed_to_update': allowed_to_update,
320 320 'review_status': status,
321 321 'review_status_label': status_lbl,
322 322 'user_group': member.user_group,
323 323 'create': false
324 324 });
325 325 $('#review_members').append(entry)
326 326 </script>
327 327
328 328 % endfor
329 329
330 330 </ul>
331 331
332 332 <input type="hidden" name="__end__" value="review_members:sequence">
333 333 ## end members redering block
334 334
335 335 %if not c.pull_request.is_closed():
336 336 <div id="add_reviewer" class="ac" style="display: none;">
337 337 %if c.allowed_to_update:
338 338 % if not c.forbid_adding_reviewers:
339 339 <div id="add_reviewer_input" class="reviewer_ac">
340 340 ${h.text('user', class_='ac-input', placeholder=_('Add reviewer or reviewer group'))}
341 341 <div id="reviewers_container"></div>
342 342 </div>
343 343 % endif
344 344 <div class="pull-right">
345 345 <button id="update_pull_request" class="btn btn-small no-margin">${_('Save Changes')}</button>
346 346 </div>
347 347 %endif
348 348 </div>
349 349 %endif
350 350 </div>
351 351 </div>
352 352 </div>
353 353 <div class="box">
354 354 ##DIFF
355 355 <div class="table" >
356 356 <div id="changeset_compare_view_content">
357 357 ##CS
358 358 % if c.missing_requirements:
359 359 <div class="box">
360 360 <div class="alert alert-warning">
361 361 <div>
362 362 <strong>${_('Missing requirements:')}</strong>
363 363 ${_('These commits cannot be displayed, because this repository uses the Mercurial largefiles extension, which was not enabled.')}
364 364 </div>
365 365 </div>
366 366 </div>
367 367 % elif c.missing_commits:
368 368 <div class="box">
369 369 <div class="alert alert-warning">
370 370 <div>
371 371 <strong>${_('Missing commits')}:</strong>
372 372 ${_('This pull request cannot be displayed, because one or more commits no longer exist in the source repository.')}
373 373 ${_('Please update this pull request, push the commits back into the source repository, or consider closing this pull request.')}
374 374 ${_('Consider doing a {force_refresh_url} in case you think this is an error.').format(force_refresh_url=h.link_to('force refresh', h.current_route_path(request, force_refresh='1')))|n}
375 375 </div>
376 376 </div>
377 377 </div>
378 378 % endif
379 379
380 380 <div class="compare_view_commits_title">
381 381 % if not c.compare_mode:
382 382
383 383 % if c.at_version_pos:
384 384 <h4>
385 385 ${_('Showing changes at v%d, commenting is disabled.') % c.at_version_pos}
386 386 </h4>
387 387 % endif
388 388
389 389 <div class="pull-left">
390 390 <div class="btn-group">
391 391 <a
392 392 class="btn"
393 393 href="#"
394 394 onclick="$('.compare_select').show();$('.compare_select_hidden').hide(); return false">
395 395 ${_ungettext('Expand %s commit','Expand %s commits', len(c.commit_ranges)) % len(c.commit_ranges)}
396 396 </a>
397 397 <a
398 398 class="btn"
399 399 href="#"
400 400 onclick="$('.compare_select').hide();$('.compare_select_hidden').show(); return false">
401 401 ${_ungettext('Collapse %s commit','Collapse %s commits', len(c.commit_ranges)) % len(c.commit_ranges)}
402 402 </a>
403 403 </div>
404 404 </div>
405 405
406 406 <div class="pull-right">
407 407 % if c.allowed_to_update and not c.pull_request.is_closed():
408 <a id="update_commits" class="btn btn-primary no-margin pull-right">${_('Update commits')}</a>
408
409 <div class="btn-group btn-group-actions">
410 <a id="update_commits" class="btn btn-primary no-margin" onclick="updateController.updateCommits(this); return false">
411 ${_('Update commits')}
412 </a>
413
414 <a id="update_commits_switcher" class="btn btn-primary" style="margin-left: -1px" data-toggle="dropdown" aria-pressed="false" role="button">
415 <i class="icon-down"></i>
416 </a>
417
418 <div class="btn-action-switcher-container" id="update-commits-switcher">
419 <ul class="btn-action-switcher" role="menu">
420 <li>
421 <a href="#forceUpdate" onclick="updateController.forceUpdateCommits(this); return false">
422 ${_('Force update commits')}
423 </a>
424 <div class="action-help-block">
425 ${_('Update commits and force refresh this pull request.')}
426 </div>
427 </li>
428 </ul>
429 </div>
430 </div>
431
409 432 % else:
410 433 <a class="tooltip btn disabled pull-right" disabled="disabled" title="${_('Update is disabled for current view')}">${_('Update commits')}</a>
411 434 % endif
412 435
413 436 </div>
414 437 % endif
415 438 </div>
416 439
417 440 % if not c.missing_commits:
418 441 % if c.compare_mode:
419 442 % if c.at_version:
420 443 <h4>
421 444 ${_('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')}:
422 445 </h4>
423 446
424 447 <div class="subtitle-compare">
425 448 ${_('commits added: {}, removed: {}').format(len(c.commit_changes_summary.added), len(c.commit_changes_summary.removed))}
426 449 </div>
427 450
428 451 <div class="container">
429 452 <table class="rctable compare_view_commits">
430 453 <tr>
431 454 <th></th>
432 455 <th>${_('Time')}</th>
433 456 <th>${_('Author')}</th>
434 457 <th>${_('Commit')}</th>
435 458 <th></th>
436 459 <th>${_('Description')}</th>
437 460 </tr>
438 461
439 462 % for c_type, commit in c.commit_changes:
440 463 % if c_type in ['a', 'r']:
441 464 <%
442 465 if c_type == 'a':
443 466 cc_title = _('Commit added in displayed changes')
444 467 elif c_type == 'r':
445 468 cc_title = _('Commit removed in displayed changes')
446 469 else:
447 470 cc_title = ''
448 471 %>
449 472 <tr id="row-${commit.raw_id}" commit_id="${commit.raw_id}" class="compare_select">
450 473 <td>
451 474 <div class="commit-change-indicator color-${c_type}-border">
452 475 <div class="commit-change-content color-${c_type} tooltip" title="${h.tooltip(cc_title)}">
453 476 ${c_type.upper()}
454 477 </div>
455 478 </div>
456 479 </td>
457 480 <td class="td-time">
458 481 ${h.age_component(commit.date)}
459 482 </td>
460 483 <td class="td-user">
461 484 ${base.gravatar_with_user(commit.author, 16, tooltip=True)}
462 485 </td>
463 486 <td class="td-hash">
464 487 <code>
465 488 <a href="${h.route_path('repo_commit', repo_name=c.target_repo.repo_name, commit_id=commit.raw_id)}">
466 489 r${commit.idx}:${h.short_id(commit.raw_id)}
467 490 </a>
468 491 ${h.hidden('revisions', commit.raw_id)}
469 492 </code>
470 493 </td>
471 494 <td class="td-message expand_commit" data-commit-id="${commit.raw_id}" title="${_( 'Expand commit message')}" onclick="commitsController.expandCommit(this); return false">
472 495 <i class="icon-expand-linked"></i>
473 496 </td>
474 497 <td class="mid td-description">
475 498 <div class="log-container truncate-wrap">
476 499 <div class="message truncate" id="c-${commit.raw_id}" data-message-raw="${commit.message}">${h.urlify_commit_message(commit.message, c.repo_name)}</div>
477 500 </div>
478 501 </td>
479 502 </tr>
480 503 % endif
481 504 % endfor
482 505 </table>
483 506 </div>
484 507
485 508 % endif
486 509
487 510 % else:
488 511 <%include file="/compare/compare_commits.mako" />
489 512 % endif
490 513
491 514 <div class="cs_files">
492 515 <%namespace name="cbdiffs" file="/codeblocks/diffs.mako"/>
493 516 % if c.at_version:
494 517 <% c.inline_cnt = len(c.inline_versions[c.at_version_num]['display']) %>
495 518 <% c.comments = c.comment_versions[c.at_version_num]['display'] %>
496 519 % else:
497 520 <% c.inline_cnt = len(c.inline_versions[c.at_version_num]['until']) %>
498 521 <% c.comments = c.comment_versions[c.at_version_num]['until'] %>
499 522 % endif
500 523
501 524 <%
502 525 pr_menu_data = {
503 526 'outdated_comm_count_ver': outdated_comm_count_ver
504 527 }
505 528 %>
506 529
507 530 ${cbdiffs.render_diffset_menu(c.diffset, range_diff_on=c.range_diff_on)}
508 531
509 532 % if c.range_diff_on:
510 533 % for commit in c.commit_ranges:
511 534 ${cbdiffs.render_diffset(
512 535 c.changes[commit.raw_id],
513 536 commit=commit, use_comments=True,
514 537 collapse_when_files_over=5,
515 538 disable_new_comments=True,
516 539 deleted_files_comments=c.deleted_files_comments,
517 540 inline_comments=c.inline_comments,
518 541 pull_request_menu=pr_menu_data)}
519 542 % endfor
520 543 % else:
521 544 ${cbdiffs.render_diffset(
522 545 c.diffset, use_comments=True,
523 546 collapse_when_files_over=30,
524 547 disable_new_comments=not c.allowed_to_comment,
525 548 deleted_files_comments=c.deleted_files_comments,
526 549 inline_comments=c.inline_comments,
527 550 pull_request_menu=pr_menu_data)}
528 551 % endif
529 552
530 553 </div>
531 554 % else:
532 555 ## skipping commits we need to clear the view for missing commits
533 556 <div style="clear:both;"></div>
534 557 % endif
535 558
536 559 </div>
537 560 </div>
538 561
539 562 ## template for inline comment form
540 563 <%namespace name="comment" file="/changeset/changeset_file_comment.mako"/>
541 564
542 565 ## comments heading with count
543 566 <div class="comments-heading">
544 567 <i class="icon-comment"></i>
545 568 ${_('Comments')} ${len(c.comments)}
546 569 </div>
547 570
548 571 ## render general comments
549 572 <div id="comment-tr-show">
550 573 % if general_outdated_comm_count_ver:
551 574 <div class="info-box">
552 575 % if general_outdated_comm_count_ver == 1:
553 576 ${_('there is {num} general comment from older versions').format(num=general_outdated_comm_count_ver)},
554 577 <a href="#show-hidden-comments" onclick="$('.comment-general.comment-outdated').show(); $(this).parent().hide(); return false;">${_('show it')}</a>
555 578 % else:
556 579 ${_('there are {num} general comments from older versions').format(num=general_outdated_comm_count_ver)},
557 580 <a href="#show-hidden-comments" onclick="$('.comment-general.comment-outdated').show(); $(this).parent().hide(); return false;">${_('show them')}</a>
558 581 % endif
559 582 </div>
560 583 % endif
561 584 </div>
562 585
563 586 ${comment.generate_comments(c.comments, include_pull_request=True, is_pull_request=True)}
564 587
565 588 % if not c.pull_request.is_closed():
566 589 ## main comment form and it status
567 590 ${comment.comments(h.route_path('pullrequest_comment_create', repo_name=c.repo_name,
568 591 pull_request_id=c.pull_request.pull_request_id),
569 592 c.pull_request_review_status,
570 593 is_pull_request=True, change_status=c.allowed_to_change_status)}
571 594
572 595 ## merge status, and merge action
573 596 <div class="pull-request-merge">
574 597 <%include file="/pullrequests/pullrequest_merge_checks.mako"/>
575 598 </div>
576 599
577 600 %endif
578 601
579 602 <script type="text/javascript">
580 603
581 604 versionController = new VersionController();
582 605 versionController.init();
583 606
584 607 reviewersController = new ReviewersController();
585 608 commitsController = new CommitsController();
586 609
610 updateController = new UpdatePrController();
611
587 612 $(function(){
588 613
589 614 // custom code mirror
590 615 var codeMirrorInstance = $('#pr-description-input').get(0).MarkupForm.cm;
591 616
592 617 var PRDetails = {
593 618 editButton: $('#open_edit_pullrequest'),
594 619 closeButton: $('#close_edit_pullrequest'),
595 620 deleteButton: $('#delete_pullrequest'),
596 621 viewFields: $('#pr-desc, #pr-title'),
597 622 editFields: $('#pr-desc-edit, #pr-title-edit, #pr-save'),
598 623
599 624 init: function() {
600 625 var that = this;
601 626 this.editButton.on('click', function(e) { that.edit(); });
602 627 this.closeButton.on('click', function(e) { that.view(); });
603 628 },
604 629
605 630 edit: function(event) {
606 631 this.viewFields.hide();
607 632 this.editButton.hide();
608 633 this.deleteButton.hide();
609 634 this.closeButton.show();
610 635 this.editFields.show();
611 636 codeMirrorInstance.refresh();
612 637 },
613 638
614 639 view: function(event) {
615 640 this.editButton.show();
616 641 this.deleteButton.show();
617 642 this.editFields.hide();
618 643 this.closeButton.hide();
619 644 this.viewFields.show();
620 645 }
621 646 };
622 647
623 648 var ReviewersPanel = {
624 649 editButton: $('#open_edit_reviewers'),
625 650 closeButton: $('#close_edit_reviewers'),
626 651 addButton: $('#add_reviewer'),
627 652 removeButtons: $('.reviewer_member_remove,.reviewer_member_mandatory_remove'),
628 653
629 654 init: function() {
630 655 var self = this;
631 656 this.editButton.on('click', function(e) { self.edit(); });
632 657 this.closeButton.on('click', function(e) { self.close(); });
633 658 },
634 659
635 660 edit: function(event) {
636 661 this.editButton.hide();
637 662 this.closeButton.show();
638 663 this.addButton.show();
639 664 this.removeButtons.css('visibility', 'visible');
640 665 // review rules
641 666 reviewersController.loadReviewRules(
642 667 ${c.pull_request.reviewer_data_json | n});
643 668 },
644 669
645 670 close: function(event) {
646 671 this.editButton.show();
647 672 this.closeButton.hide();
648 673 this.addButton.hide();
649 674 this.removeButtons.css('visibility', 'hidden');
650 675 // hide review rules
651 676 reviewersController.hideReviewRules()
652 677 }
653 678 };
654 679
655 680 PRDetails.init();
656 681 ReviewersPanel.init();
657 682
658 683 showOutdated = function(self){
659 684 $('.comment-inline.comment-outdated').show();
660 685 $('.filediff-outdated').show();
661 686 $('.showOutdatedComments').hide();
662 687 $('.hideOutdatedComments').show();
663 688 };
664 689
665 690 hideOutdated = function(self){
666 691 $('.comment-inline.comment-outdated').hide();
667 692 $('.filediff-outdated').hide();
668 693 $('.hideOutdatedComments').hide();
669 694 $('.showOutdatedComments').show();
670 695 };
671 696
672 697 refreshMergeChecks = function(){
673 698 var loadUrl = "${request.current_route_path(_query=dict(merge_checks=1))}";
674 699 $('.pull-request-merge').css('opacity', 0.3);
675 700 $('.action-buttons-extra').css('opacity', 0.3);
676 701
677 702 $('.pull-request-merge').load(
678 703 loadUrl, function() {
679 704 $('.pull-request-merge').css('opacity', 1);
680 705
681 706 $('.action-buttons-extra').css('opacity', 1);
682 707 }
683 708 );
684 709 };
685 710
686 711 closePullRequest = function (status) {
687 712 if (!confirm(_gettext('Are you sure to close this pull request without merging?'))) {
688 713 return false;
689 714 }
690 715 // inject closing flag
691 716 $('.action-buttons-extra').append('<input type="hidden" class="close-pr-input" id="close_pull_request" value="1">');
692 717 $(generalCommentForm.statusChange).select2("val", status).trigger('change');
693 718 $(generalCommentForm.submitForm).submit();
694 719 };
695 720
696 721 $('#show-outdated-comments').on('click', function(e){
697 722 var button = $(this);
698 723 var outdated = $('.comment-outdated');
699 724
700 725 if (button.html() === "(Show)") {
701 726 button.html("(Hide)");
702 727 outdated.show();
703 728 } else {
704 729 button.html("(Show)");
705 730 outdated.hide();
706 731 }
707 732 });
708 733
709 734 $('.show-inline-comments').on('change', function(e){
710 735 var show = 'none';
711 736 var target = e.currentTarget;
712 737 if(target.checked){
713 738 show = ''
714 739 }
715 740 var boxid = $(target).attr('id_for');
716 741 var comments = $('#{0} .inline-comments'.format(boxid));
717 742 var fn_display = function(idx){
718 743 $(this).css('display', show);
719 744 };
720 745 $(comments).each(fn_display);
721 746 var btns = $('#{0} .inline-comments-button'.format(boxid));
722 747 $(btns).each(fn_display);
723 748 });
724 749
725 750 $('#merge_pull_request_form').submit(function() {
726 751 if (!$('#merge_pull_request').attr('disabled')) {
727 752 $('#merge_pull_request').attr('disabled', 'disabled');
728 753 }
729 754 return true;
730 755 });
731 756
732 757 $('#edit_pull_request').on('click', function(e){
733 758 var title = $('#pr-title-input').val();
734 759 var description = codeMirrorInstance.getValue();
735 760 var renderer = $('#pr-renderer-input').val();
736 761 editPullRequest(
737 762 "${c.repo_name}", "${c.pull_request.pull_request_id}",
738 763 title, description, renderer);
739 764 });
740 765
741 766 $('#update_pull_request').on('click', function(e){
742 767 $(this).attr('disabled', 'disabled');
743 768 $(this).addClass('disabled');
744 769 $(this).html(_gettext('Saving...'));
745 770 reviewersController.updateReviewers(
746 771 "${c.repo_name}", "${c.pull_request.pull_request_id}");
747 772 });
748 773
749 $('#update_commits').on('click', function(e){
750 var isDisabled = !$(e.currentTarget).attr('disabled');
751 $(e.currentTarget).attr('disabled', 'disabled');
752 $(e.currentTarget).addClass('disabled');
753 $(e.currentTarget).removeClass('btn-primary');
754 $(e.currentTarget).text(_gettext('Updating...'));
755 if(isDisabled){
756 updateCommits(
757 "${c.repo_name}", "${c.pull_request.pull_request_id}");
758 }
759 });
774
760 775 // fixing issue with caches on firefox
761 776 $('#update_commits').removeAttr("disabled");
762 777
763 778 $('.show-inline-comments').on('click', function(e){
764 779 var boxid = $(this).attr('data-comment-id');
765 780 var button = $(this);
766 781
767 782 if(button.hasClass("comments-visible")) {
768 783 $('#{0} .inline-comments'.format(boxid)).each(function(index){
769 784 $(this).hide();
770 785 });
771 786 button.removeClass("comments-visible");
772 787 } else {
773 788 $('#{0} .inline-comments'.format(boxid)).each(function(index){
774 789 $(this).show();
775 790 });
776 791 button.addClass("comments-visible");
777 792 }
778 793 });
779 794
780 795 // register submit callback on commentForm form to track TODOs
781 796 window.commentFormGlobalSubmitSuccessCallback = function(){
782 797 refreshMergeChecks();
783 798 };
784 799
785 800 ReviewerAutoComplete('#user');
786 801
787 802 })
788 803
789 804 </script>
790 805
791 806 </div>
792 807 </div>
793 808
794 809 </%def>
General Comments 0
You need to be logged in to leave comments. Login now