##// END OF EJS Templates
pull-requests: unified merge checks....
marcink -
r1335:7ea0471c default
parent child Browse files
Show More
@@ -1,714 +1,715 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 import logging
23 23
24 24 from rhodecode.api import jsonrpc_method, JSONRPCError
25 25 from rhodecode.api.utils import (
26 26 has_superadmin_permission, Optional, OAttr, get_repo_or_error,
27 27 get_pull_request_or_error, get_commit_or_error, get_user_or_error,
28 28 validate_repo_permissions, resolve_ref_or_error)
29 29 from rhodecode.lib.auth import (HasRepoPermissionAnyApi)
30 30 from rhodecode.lib.base import vcs_operation_context
31 31 from rhodecode.lib.utils2 import str2bool
32 32 from rhodecode.model.changeset_status import ChangesetStatusModel
33 33 from rhodecode.model.comment import CommentsModel
34 34 from rhodecode.model.db import Session, ChangesetStatus
35 from rhodecode.model.pull_request import PullRequestModel
35 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
36 36 from rhodecode.model.settings import SettingsModel
37 37
38 38 log = logging.getLogger(__name__)
39 39
40 40
41 41 @jsonrpc_method()
42 42 def get_pull_request(request, apiuser, repoid, pullrequestid):
43 43 """
44 44 Get a pull request based on the given ID.
45 45
46 46 :param apiuser: This is filled automatically from the |authtoken|.
47 47 :type apiuser: AuthUser
48 48 :param repoid: Repository name or repository ID from where the pull
49 49 request was opened.
50 50 :type repoid: str or int
51 51 :param pullrequestid: ID of the requested pull request.
52 52 :type pullrequestid: int
53 53
54 54 Example output:
55 55
56 56 .. code-block:: bash
57 57
58 58 "id": <id_given_in_input>,
59 59 "result":
60 60 {
61 61 "pull_request_id": "<pull_request_id>",
62 62 "url": "<url>",
63 63 "title": "<title>",
64 64 "description": "<description>",
65 65 "status" : "<status>",
66 66 "created_on": "<date_time_created>",
67 67 "updated_on": "<date_time_updated>",
68 68 "commit_ids": [
69 69 ...
70 70 "<commit_id>",
71 71 "<commit_id>",
72 72 ...
73 73 ],
74 74 "review_status": "<review_status>",
75 75 "mergeable": {
76 76 "status": "<bool>",
77 77 "message": "<message>",
78 78 },
79 79 "source": {
80 80 "clone_url": "<clone_url>",
81 81 "repository": "<repository_name>",
82 82 "reference":
83 83 {
84 84 "name": "<name>",
85 85 "type": "<type>",
86 86 "commit_id": "<commit_id>",
87 87 }
88 88 },
89 89 "target": {
90 90 "clone_url": "<clone_url>",
91 91 "repository": "<repository_name>",
92 92 "reference":
93 93 {
94 94 "name": "<name>",
95 95 "type": "<type>",
96 96 "commit_id": "<commit_id>",
97 97 }
98 98 },
99 99 "merge": {
100 100 "clone_url": "<clone_url>",
101 101 "reference":
102 102 {
103 103 "name": "<name>",
104 104 "type": "<type>",
105 105 "commit_id": "<commit_id>",
106 106 }
107 107 },
108 108 "author": <user_obj>,
109 109 "reviewers": [
110 110 ...
111 111 {
112 112 "user": "<user_obj>",
113 113 "review_status": "<review_status>",
114 114 }
115 115 ...
116 116 ]
117 117 },
118 118 "error": null
119 119 """
120 120 get_repo_or_error(repoid)
121 121 pull_request = get_pull_request_or_error(pullrequestid)
122 122 if not PullRequestModel().check_user_read(
123 123 pull_request, apiuser, api=True):
124 124 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
125 125 data = pull_request.get_api_data()
126 126 return data
127 127
128 128
129 129 @jsonrpc_method()
130 130 def get_pull_requests(request, apiuser, repoid, status=Optional('new')):
131 131 """
132 132 Get all pull requests from the repository specified in `repoid`.
133 133
134 134 :param apiuser: This is filled automatically from the |authtoken|.
135 135 :type apiuser: AuthUser
136 136 :param repoid: Repository name or repository ID.
137 137 :type repoid: str or int
138 138 :param status: Only return pull requests with the specified status.
139 139 Valid options are.
140 140 * ``new`` (default)
141 141 * ``open``
142 142 * ``closed``
143 143 :type status: str
144 144
145 145 Example output:
146 146
147 147 .. code-block:: bash
148 148
149 149 "id": <id_given_in_input>,
150 150 "result":
151 151 [
152 152 ...
153 153 {
154 154 "pull_request_id": "<pull_request_id>",
155 155 "url": "<url>",
156 156 "title" : "<title>",
157 157 "description": "<description>",
158 158 "status": "<status>",
159 159 "created_on": "<date_time_created>",
160 160 "updated_on": "<date_time_updated>",
161 161 "commit_ids": [
162 162 ...
163 163 "<commit_id>",
164 164 "<commit_id>",
165 165 ...
166 166 ],
167 167 "review_status": "<review_status>",
168 168 "mergeable": {
169 169 "status": "<bool>",
170 170 "message: "<message>",
171 171 },
172 172 "source": {
173 173 "clone_url": "<clone_url>",
174 174 "reference":
175 175 {
176 176 "name": "<name>",
177 177 "type": "<type>",
178 178 "commit_id": "<commit_id>",
179 179 }
180 180 },
181 181 "target": {
182 182 "clone_url": "<clone_url>",
183 183 "reference":
184 184 {
185 185 "name": "<name>",
186 186 "type": "<type>",
187 187 "commit_id": "<commit_id>",
188 188 }
189 189 },
190 190 "merge": {
191 191 "clone_url": "<clone_url>",
192 192 "reference":
193 193 {
194 194 "name": "<name>",
195 195 "type": "<type>",
196 196 "commit_id": "<commit_id>",
197 197 }
198 198 },
199 199 "author": <user_obj>,
200 200 "reviewers": [
201 201 ...
202 202 {
203 203 "user": "<user_obj>",
204 204 "review_status": "<review_status>",
205 205 }
206 206 ...
207 207 ]
208 208 }
209 209 ...
210 210 ],
211 211 "error": null
212 212
213 213 """
214 214 repo = get_repo_or_error(repoid)
215 215 if not has_superadmin_permission(apiuser):
216 216 _perms = (
217 217 'repository.admin', 'repository.write', 'repository.read',)
218 218 validate_repo_permissions(apiuser, repoid, repo, _perms)
219 219
220 220 status = Optional.extract(status)
221 221 pull_requests = PullRequestModel().get_all(repo, statuses=[status])
222 222 data = [pr.get_api_data() for pr in pull_requests]
223 223 return data
224 224
225 225
226 226 @jsonrpc_method()
227 227 def merge_pull_request(request, apiuser, repoid, pullrequestid,
228 228 userid=Optional(OAttr('apiuser'))):
229 229 """
230 230 Merge the pull request specified by `pullrequestid` into its target
231 231 repository.
232 232
233 233 :param apiuser: This is filled automatically from the |authtoken|.
234 234 :type apiuser: AuthUser
235 235 :param repoid: The Repository name or repository ID of the
236 236 target repository to which the |pr| is to be merged.
237 237 :type repoid: str or int
238 238 :param pullrequestid: ID of the pull request which shall be merged.
239 239 :type pullrequestid: int
240 240 :param userid: Merge the pull request as this user.
241 241 :type userid: Optional(str or int)
242 242
243 243 Example output:
244 244
245 245 .. code-block:: bash
246 246
247 247 "id": <id_given_in_input>,
248 248 "result":
249 249 {
250 250 "executed": "<bool>",
251 251 "failure_reason": "<int>",
252 252 "merge_commit_id": "<merge_commit_id>",
253 253 "possible": "<bool>",
254 254 "merge_ref": {
255 255 "commit_id": "<commit_id>",
256 256 "type": "<type>",
257 257 "name": "<name>"
258 258 }
259 259 },
260 260 "error": null
261 261
262 262 """
263 263 repo = get_repo_or_error(repoid)
264 264 if not isinstance(userid, Optional):
265 265 if (has_superadmin_permission(apiuser) or
266 266 HasRepoPermissionAnyApi('repository.admin')(
267 267 user=apiuser, repo_name=repo.repo_name)):
268 268 apiuser = get_user_or_error(userid)
269 269 else:
270 270 raise JSONRPCError('userid is not the same as your user')
271 271
272 272 pull_request = get_pull_request_or_error(pullrequestid)
273 if not PullRequestModel().check_user_merge(
274 pull_request, apiuser, api=True):
275 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
276 if pull_request.is_closed():
273
274 check = MergeCheck.validate(pull_request, user=apiuser)
275 merge_possible = not check.failed
276
277 if not merge_possible:
278 reasons = ','.join([msg for _e, msg in check.errors])
277 279 raise JSONRPCError(
278 'pull request `%s` merge failed, pull request is closed' % (
279 pullrequestid,))
280 'merge not possible for following reasons: {}'.format(reasons))
280 281
281 282 target_repo = pull_request.target_repo
282 283 extras = vcs_operation_context(
283 284 request.environ, repo_name=target_repo.repo_name,
284 285 username=apiuser.username, action='push',
285 286 scm=target_repo.repo_type)
286 287 merge_response = PullRequestModel().merge(
287 288 pull_request, apiuser, extras=extras)
288 289 if merge_response.executed:
289 290 PullRequestModel().close_pull_request(
290 291 pull_request.pull_request_id, apiuser)
291 292
292 293 Session().commit()
293 294
294 295 # In previous versions the merge response directly contained the merge
295 296 # commit id. It is now contained in the merge reference object. To be
296 297 # backwards compatible we have to extract it again.
297 298 merge_response = merge_response._asdict()
298 299 merge_response['merge_commit_id'] = merge_response['merge_ref'].commit_id
299 300
300 301 return merge_response
301 302
302 303
303 304 @jsonrpc_method()
304 305 def close_pull_request(request, apiuser, repoid, pullrequestid,
305 306 userid=Optional(OAttr('apiuser'))):
306 307 """
307 308 Close the pull request specified by `pullrequestid`.
308 309
309 310 :param apiuser: This is filled automatically from the |authtoken|.
310 311 :type apiuser: AuthUser
311 312 :param repoid: Repository name or repository ID to which the pull
312 313 request belongs.
313 314 :type repoid: str or int
314 315 :param pullrequestid: ID of the pull request to be closed.
315 316 :type pullrequestid: int
316 317 :param userid: Close the pull request as this user.
317 318 :type userid: Optional(str or int)
318 319
319 320 Example output:
320 321
321 322 .. code-block:: bash
322 323
323 324 "id": <id_given_in_input>,
324 325 "result":
325 326 {
326 327 "pull_request_id": "<int>",
327 328 "closed": "<bool>"
328 329 },
329 330 "error": null
330 331
331 332 """
332 333 repo = get_repo_or_error(repoid)
333 334 if not isinstance(userid, Optional):
334 335 if (has_superadmin_permission(apiuser) or
335 336 HasRepoPermissionAnyApi('repository.admin')(
336 337 user=apiuser, repo_name=repo.repo_name)):
337 338 apiuser = get_user_or_error(userid)
338 339 else:
339 340 raise JSONRPCError('userid is not the same as your user')
340 341
341 342 pull_request = get_pull_request_or_error(pullrequestid)
342 343 if not PullRequestModel().check_user_update(
343 344 pull_request, apiuser, api=True):
344 345 raise JSONRPCError(
345 346 'pull request `%s` close failed, no permission to close.' % (
346 347 pullrequestid,))
347 348 if pull_request.is_closed():
348 349 raise JSONRPCError(
349 350 'pull request `%s` is already closed' % (pullrequestid,))
350 351
351 352 PullRequestModel().close_pull_request(
352 353 pull_request.pull_request_id, apiuser)
353 354 Session().commit()
354 355 data = {
355 356 'pull_request_id': pull_request.pull_request_id,
356 357 'closed': True,
357 358 }
358 359 return data
359 360
360 361
361 362 @jsonrpc_method()
362 363 def comment_pull_request(request, apiuser, repoid, pullrequestid,
363 364 message=Optional(None), status=Optional(None),
364 365 commit_id=Optional(None),
365 366 userid=Optional(OAttr('apiuser'))):
366 367 """
367 368 Comment on the pull request specified with the `pullrequestid`,
368 369 in the |repo| specified by the `repoid`, and optionally change the
369 370 review status.
370 371
371 372 :param apiuser: This is filled automatically from the |authtoken|.
372 373 :type apiuser: AuthUser
373 374 :param repoid: The repository name or repository ID.
374 375 :type repoid: str or int
375 376 :param pullrequestid: The pull request ID.
376 377 :type pullrequestid: int
377 378 :param message: The text content of the comment.
378 379 :type message: str
379 380 :param status: (**Optional**) Set the approval status of the pull
380 381 request. Valid options are:
381 382 * not_reviewed
382 383 * approved
383 384 * rejected
384 385 * under_review
385 386 :type status: str
386 387 :param commit_id: Specify the commit_id for which to set a comment. If
387 388 given commit_id is different than latest in the PR status
388 389 change won't be performed.
389 390 :type commit_id: str
390 391 :param userid: Comment on the pull request as this user
391 392 :type userid: Optional(str or int)
392 393
393 394 Example output:
394 395
395 396 .. code-block:: bash
396 397
397 398 id : <id_given_in_input>
398 399 result :
399 400 {
400 401 "pull_request_id": "<Integer>",
401 402 "comment_id": "<Integer>",
402 403 "status": {"given": <given_status>,
403 404 "was_changed": <bool status_was_actually_changed> },
404 405 }
405 406 error : null
406 407 """
407 408 repo = get_repo_or_error(repoid)
408 409 if not isinstance(userid, Optional):
409 410 if (has_superadmin_permission(apiuser) or
410 411 HasRepoPermissionAnyApi('repository.admin')(
411 412 user=apiuser, repo_name=repo.repo_name)):
412 413 apiuser = get_user_or_error(userid)
413 414 else:
414 415 raise JSONRPCError('userid is not the same as your user')
415 416
416 417 pull_request = get_pull_request_or_error(pullrequestid)
417 418 if not PullRequestModel().check_user_read(
418 419 pull_request, apiuser, api=True):
419 420 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
420 421 message = Optional.extract(message)
421 422 status = Optional.extract(status)
422 423 commit_id = Optional.extract(commit_id)
423 424
424 425 if not message and not status:
425 426 raise JSONRPCError(
426 427 'Both message and status parameters are missing. '
427 428 'At least one is required.')
428 429
429 430 if (status not in (st[0] for st in ChangesetStatus.STATUSES) and
430 431 status is not None):
431 432 raise JSONRPCError('Unknown comment status: `%s`' % status)
432 433
433 434 if commit_id and commit_id not in pull_request.revisions:
434 435 raise JSONRPCError(
435 436 'Invalid commit_id `%s` for this pull request.' % commit_id)
436 437
437 438 allowed_to_change_status = PullRequestModel().check_user_change_status(
438 439 pull_request, apiuser)
439 440
440 441 # if commit_id is passed re-validated if user is allowed to change status
441 442 # based on latest commit_id from the PR
442 443 if commit_id:
443 444 commit_idx = pull_request.revisions.index(commit_id)
444 445 if commit_idx != 0:
445 446 allowed_to_change_status = False
446 447
447 448 text = message
448 449 status_label = ChangesetStatus.get_status_lbl(status)
449 450 if status and allowed_to_change_status:
450 451 st_message = ('Status change %(transition_icon)s %(status)s'
451 452 % {'transition_icon': '>', 'status': status_label})
452 453 text = message or st_message
453 454
454 455 rc_config = SettingsModel().get_all_settings()
455 456 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
456 457
457 458 status_change = status and allowed_to_change_status
458 459 comment = CommentsModel().create(
459 460 text=text,
460 461 repo=pull_request.target_repo.repo_id,
461 462 user=apiuser.user_id,
462 463 pull_request=pull_request.pull_request_id,
463 464 f_path=None,
464 465 line_no=None,
465 466 status_change=(status_label if status_change else None),
466 467 status_change_type=(status if status_change else None),
467 468 closing_pr=False,
468 469 renderer=renderer
469 470 )
470 471
471 472 if allowed_to_change_status and status:
472 473 ChangesetStatusModel().set_status(
473 474 pull_request.target_repo.repo_id,
474 475 status,
475 476 apiuser.user_id,
476 477 comment,
477 478 pull_request=pull_request.pull_request_id
478 479 )
479 480 Session().flush()
480 481
481 482 Session().commit()
482 483 data = {
483 484 'pull_request_id': pull_request.pull_request_id,
484 485 'comment_id': comment.comment_id if comment else None,
485 486 'status': {'given': status, 'was_changed': status_change},
486 487 }
487 488 return data
488 489
489 490
490 491 @jsonrpc_method()
491 492 def create_pull_request(
492 493 request, apiuser, source_repo, target_repo, source_ref, target_ref,
493 494 title, description=Optional(''), reviewers=Optional(None)):
494 495 """
495 496 Creates a new pull request.
496 497
497 498 Accepts refs in the following formats:
498 499
499 500 * branch:<branch_name>:<sha>
500 501 * branch:<branch_name>
501 502 * bookmark:<bookmark_name>:<sha> (Mercurial only)
502 503 * bookmark:<bookmark_name> (Mercurial only)
503 504
504 505 :param apiuser: This is filled automatically from the |authtoken|.
505 506 :type apiuser: AuthUser
506 507 :param source_repo: Set the source repository name.
507 508 :type source_repo: str
508 509 :param target_repo: Set the target repository name.
509 510 :type target_repo: str
510 511 :param source_ref: Set the source ref name.
511 512 :type source_ref: str
512 513 :param target_ref: Set the target ref name.
513 514 :type target_ref: str
514 515 :param title: Set the pull request title.
515 516 :type title: str
516 517 :param description: Set the pull request description.
517 518 :type description: Optional(str)
518 519 :param reviewers: Set the new pull request reviewers list.
519 520 :type reviewers: Optional(list)
520 521 Accepts username strings or objects of the format:
521 522 {
522 523 'username': 'nick', 'reasons': ['original author']
523 524 }
524 525 """
525 526
526 527 source = get_repo_or_error(source_repo)
527 528 target = get_repo_or_error(target_repo)
528 529 if not has_superadmin_permission(apiuser):
529 530 _perms = ('repository.admin', 'repository.write', 'repository.read',)
530 531 validate_repo_permissions(apiuser, source_repo, source, _perms)
531 532
532 533 full_source_ref = resolve_ref_or_error(source_ref, source)
533 534 full_target_ref = resolve_ref_or_error(target_ref, target)
534 535 source_commit = get_commit_or_error(full_source_ref, source)
535 536 target_commit = get_commit_or_error(full_target_ref, target)
536 537 source_scm = source.scm_instance()
537 538 target_scm = target.scm_instance()
538 539
539 540 commit_ranges = target_scm.compare(
540 541 target_commit.raw_id, source_commit.raw_id, source_scm,
541 542 merge=True, pre_load=[])
542 543
543 544 ancestor = target_scm.get_common_ancestor(
544 545 target_commit.raw_id, source_commit.raw_id, source_scm)
545 546
546 547 if not commit_ranges:
547 548 raise JSONRPCError('no commits found')
548 549
549 550 if not ancestor:
550 551 raise JSONRPCError('no common ancestor found')
551 552
552 553 reviewer_objects = Optional.extract(reviewers) or []
553 554 if not isinstance(reviewer_objects, list):
554 555 raise JSONRPCError('reviewers should be specified as a list')
555 556
556 557 reviewers_reasons = []
557 558 for reviewer_object in reviewer_objects:
558 559 reviewer_reasons = []
559 560 if isinstance(reviewer_object, (basestring, int)):
560 561 reviewer_username = reviewer_object
561 562 else:
562 563 reviewer_username = reviewer_object['username']
563 564 reviewer_reasons = reviewer_object.get('reasons', [])
564 565
565 566 user = get_user_or_error(reviewer_username)
566 567 reviewers_reasons.append((user.user_id, reviewer_reasons))
567 568
568 569 pull_request_model = PullRequestModel()
569 570 pull_request = pull_request_model.create(
570 571 created_by=apiuser.user_id,
571 572 source_repo=source_repo,
572 573 source_ref=full_source_ref,
573 574 target_repo=target_repo,
574 575 target_ref=full_target_ref,
575 576 revisions=reversed(
576 577 [commit.raw_id for commit in reversed(commit_ranges)]),
577 578 reviewers=reviewers_reasons,
578 579 title=title,
579 580 description=Optional.extract(description)
580 581 )
581 582
582 583 Session().commit()
583 584 data = {
584 585 'msg': 'Created new pull request `{}`'.format(title),
585 586 'pull_request_id': pull_request.pull_request_id,
586 587 }
587 588 return data
588 589
589 590
590 591 @jsonrpc_method()
591 592 def update_pull_request(
592 593 request, apiuser, repoid, pullrequestid, title=Optional(''),
593 594 description=Optional(''), reviewers=Optional(None),
594 595 update_commits=Optional(None), close_pull_request=Optional(None)):
595 596 """
596 597 Updates a pull request.
597 598
598 599 :param apiuser: This is filled automatically from the |authtoken|.
599 600 :type apiuser: AuthUser
600 601 :param repoid: The repository name or repository ID.
601 602 :type repoid: str or int
602 603 :param pullrequestid: The pull request ID.
603 604 :type pullrequestid: int
604 605 :param title: Set the pull request title.
605 606 :type title: str
606 607 :param description: Update pull request description.
607 608 :type description: Optional(str)
608 609 :param reviewers: Update pull request reviewers list with new value.
609 610 :type reviewers: Optional(list)
610 611 :param update_commits: Trigger update of commits for this pull request
611 612 :type: update_commits: Optional(bool)
612 613 :param close_pull_request: Close this pull request with rejected state
613 614 :type: close_pull_request: Optional(bool)
614 615
615 616 Example output:
616 617
617 618 .. code-block:: bash
618 619
619 620 id : <id_given_in_input>
620 621 result :
621 622 {
622 623 "msg": "Updated pull request `63`",
623 624 "pull_request": <pull_request_object>,
624 625 "updated_reviewers": {
625 626 "added": [
626 627 "username"
627 628 ],
628 629 "removed": []
629 630 },
630 631 "updated_commits": {
631 632 "added": [
632 633 "<sha1_hash>"
633 634 ],
634 635 "common": [
635 636 "<sha1_hash>",
636 637 "<sha1_hash>",
637 638 ],
638 639 "removed": []
639 640 }
640 641 }
641 642 error : null
642 643 """
643 644
644 645 repo = get_repo_or_error(repoid)
645 646 pull_request = get_pull_request_or_error(pullrequestid)
646 647 if not PullRequestModel().check_user_update(
647 648 pull_request, apiuser, api=True):
648 649 raise JSONRPCError(
649 650 'pull request `%s` update failed, no permission to update.' % (
650 651 pullrequestid,))
651 652 if pull_request.is_closed():
652 653 raise JSONRPCError(
653 654 'pull request `%s` update failed, pull request is closed' % (
654 655 pullrequestid,))
655 656
656 657 reviewer_objects = Optional.extract(reviewers) or []
657 658 if not isinstance(reviewer_objects, list):
658 659 raise JSONRPCError('reviewers should be specified as a list')
659 660
660 661 reviewers_reasons = []
661 662 reviewer_ids = set()
662 663 for reviewer_object in reviewer_objects:
663 664 reviewer_reasons = []
664 665 if isinstance(reviewer_object, (int, basestring)):
665 666 reviewer_username = reviewer_object
666 667 else:
667 668 reviewer_username = reviewer_object['username']
668 669 reviewer_reasons = reviewer_object.get('reasons', [])
669 670
670 671 user = get_user_or_error(reviewer_username)
671 672 reviewer_ids.add(user.user_id)
672 673 reviewers_reasons.append((user.user_id, reviewer_reasons))
673 674
674 675 title = Optional.extract(title)
675 676 description = Optional.extract(description)
676 677 if title or description:
677 678 PullRequestModel().edit(
678 679 pull_request, title or pull_request.title,
679 680 description or pull_request.description)
680 681 Session().commit()
681 682
682 683 commit_changes = {"added": [], "common": [], "removed": []}
683 684 if str2bool(Optional.extract(update_commits)):
684 685 if PullRequestModel().has_valid_update_type(pull_request):
685 686 update_response = PullRequestModel().update_commits(
686 687 pull_request)
687 688 commit_changes = update_response.changes or commit_changes
688 689 Session().commit()
689 690
690 691 reviewers_changes = {"added": [], "removed": []}
691 692 if reviewer_ids:
692 693 added_reviewers, removed_reviewers = \
693 694 PullRequestModel().update_reviewers(pull_request, reviewers_reasons)
694 695
695 696 reviewers_changes['added'] = sorted(
696 697 [get_user_or_error(n).username for n in added_reviewers])
697 698 reviewers_changes['removed'] = sorted(
698 699 [get_user_or_error(n).username for n in removed_reviewers])
699 700 Session().commit()
700 701
701 702 if str2bool(Optional.extract(close_pull_request)):
702 703 PullRequestModel().close_pull_request_with_comment(
703 704 pull_request, apiuser, repo)
704 705 Session().commit()
705 706
706 707 data = {
707 708 'msg': 'Updated pull request `{}`'.format(
708 709 pull_request.pull_request_id),
709 710 'pull_request': pull_request.get_api_data(),
710 711 'updated_commits': commit_changes,
711 712 'updated_reviewers': reviewers_changes
712 713 }
713 714
714 715 return data
@@ -1,1046 +1,1009 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 pull requests controller for rhodecode for initializing pull requests
23 23 """
24 24 import types
25 25
26 26 import peppercorn
27 27 import formencode
28 28 import logging
29 29 import collections
30 30
31 31 from webob.exc import HTTPNotFound, HTTPForbidden, HTTPBadRequest
32 32 from pylons import request, tmpl_context as c, url
33 33 from pylons.controllers.util import redirect
34 34 from pylons.i18n.translation import _
35 35 from pyramid.threadlocal import get_current_registry
36 36 from sqlalchemy.sql import func
37 37 from sqlalchemy.sql.expression import or_
38 38
39 39 from rhodecode import events
40 40 from rhodecode.lib import auth, diffs, helpers as h, codeblocks
41 41 from rhodecode.lib.ext_json import json
42 42 from rhodecode.lib.base import (
43 43 BaseRepoController, render, vcs_operation_context)
44 44 from rhodecode.lib.auth import (
45 45 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous,
46 46 HasAcceptedRepoType, XHRRequired)
47 47 from rhodecode.lib.channelstream import channelstream_request
48 48 from rhodecode.lib.utils import jsonify
49 49 from rhodecode.lib.utils2 import (
50 50 safe_int, safe_str, str2bool, safe_unicode)
51 51 from rhodecode.lib.vcs.backends.base import (
52 52 EmptyCommit, UpdateFailureReason, EmptyRepository)
53 53 from rhodecode.lib.vcs.exceptions import (
54 54 EmptyRepositoryError, CommitDoesNotExistError, RepositoryRequirementError,
55 55 NodeDoesNotExistError)
56 56
57 57 from rhodecode.model.changeset_status import ChangesetStatusModel
58 58 from rhodecode.model.comment import CommentsModel
59 59 from rhodecode.model.db import (PullRequest, ChangesetStatus, ChangesetComment,
60 60 Repository, PullRequestVersion)
61 61 from rhodecode.model.forms import PullRequestForm
62 62 from rhodecode.model.meta import Session
63 from rhodecode.model.pull_request import PullRequestModel
63 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
64 64
65 65 log = logging.getLogger(__name__)
66 66
67 67
68 68 class PullrequestsController(BaseRepoController):
69 69 def __before__(self):
70 70 super(PullrequestsController, self).__before__()
71 71
72 72 def _load_compare_data(self, pull_request, inline_comments):
73 73 """
74 74 Load context data needed for generating compare diff
75 75
76 76 :param pull_request: object related to the request
77 77 :param enable_comments: flag to determine if comments are included
78 78 """
79 79 source_repo = pull_request.source_repo
80 80 source_ref_id = pull_request.source_ref_parts.commit_id
81 81
82 82 target_repo = pull_request.target_repo
83 83 target_ref_id = pull_request.target_ref_parts.commit_id
84 84
85 85 # despite opening commits for bookmarks/branches/tags, we always
86 86 # convert this to rev to prevent changes after bookmark or branch change
87 87 c.source_ref_type = 'rev'
88 88 c.source_ref = source_ref_id
89 89
90 90 c.target_ref_type = 'rev'
91 91 c.target_ref = target_ref_id
92 92
93 93 c.source_repo = source_repo
94 94 c.target_repo = target_repo
95 95
96 96 c.fulldiff = bool(request.GET.get('fulldiff'))
97 97
98 98 # diff_limit is the old behavior, will cut off the whole diff
99 99 # if the limit is applied otherwise will just hide the
100 100 # big files from the front-end
101 101 diff_limit = self.cut_off_limit_diff
102 102 file_limit = self.cut_off_limit_file
103 103
104 104 pre_load = ["author", "branch", "date", "message"]
105 105
106 106 c.commit_ranges = []
107 107 source_commit = EmptyCommit()
108 108 target_commit = EmptyCommit()
109 109 c.missing_requirements = False
110 110 try:
111 111 c.commit_ranges = [
112 112 source_repo.get_commit(commit_id=rev, pre_load=pre_load)
113 113 for rev in pull_request.revisions]
114 114
115 115 c.statuses = source_repo.statuses(
116 116 [x.raw_id for x in c.commit_ranges])
117 117
118 118 target_commit = source_repo.get_commit(
119 119 commit_id=safe_str(target_ref_id))
120 120 source_commit = source_repo.get_commit(
121 121 commit_id=safe_str(source_ref_id))
122 122 except RepositoryRequirementError:
123 123 c.missing_requirements = True
124 124
125 125 # auto collapse if we have more than limit
126 126 collapse_limit = diffs.DiffProcessor._collapse_commits_over
127 127 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
128 128
129 129 c.changes = {}
130 130 c.missing_commits = False
131 131 if (c.missing_requirements or
132 132 isinstance(source_commit, EmptyCommit) or
133 133 source_commit == target_commit):
134 134 _parsed = []
135 135 c.missing_commits = True
136 136 else:
137 137 vcs_diff = PullRequestModel().get_diff(pull_request)
138 138 diff_processor = diffs.DiffProcessor(
139 139 vcs_diff, format='newdiff', diff_limit=diff_limit,
140 140 file_limit=file_limit, show_full_diff=c.fulldiff)
141 141
142 142 _parsed = diff_processor.prepare()
143 143 c.limited_diff = isinstance(_parsed, diffs.LimitedDiffContainer)
144 144
145 145 included_files = {}
146 146 for f in _parsed:
147 147 included_files[f['filename']] = f['stats']
148 148
149 149 c.deleted_files = [fname for fname in inline_comments if
150 150 fname not in included_files]
151 151
152 152 c.deleted_files_comments = collections.defaultdict(dict)
153 153 for fname, per_line_comments in inline_comments.items():
154 154 if fname in c.deleted_files:
155 155 c.deleted_files_comments[fname]['stats'] = 0
156 156 c.deleted_files_comments[fname]['comments'] = list()
157 157 for lno, comments in per_line_comments.items():
158 158 c.deleted_files_comments[fname]['comments'].extend(comments)
159 159
160 160 def _node_getter(commit):
161 161 def get_node(fname):
162 162 try:
163 163 return commit.get_node(fname)
164 164 except NodeDoesNotExistError:
165 165 return None
166 166 return get_node
167 167
168 168 c.diffset = codeblocks.DiffSet(
169 169 repo_name=c.repo_name,
170 170 source_repo_name=c.source_repo.repo_name,
171 171 source_node_getter=_node_getter(target_commit),
172 172 target_node_getter=_node_getter(source_commit),
173 173 comments=inline_comments
174 174 ).render_patchset(_parsed, target_commit.raw_id, source_commit.raw_id)
175 175
176 176 def _extract_ordering(self, request):
177 177 column_index = safe_int(request.GET.get('order[0][column]'))
178 178 order_dir = request.GET.get('order[0][dir]', 'desc')
179 179 order_by = request.GET.get(
180 180 'columns[%s][data][sort]' % column_index, 'name_raw')
181 181 return order_by, order_dir
182 182
183 183 @LoginRequired()
184 184 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
185 185 'repository.admin')
186 186 @HasAcceptedRepoType('git', 'hg')
187 187 def show_all(self, repo_name):
188 188 # filter types
189 189 c.active = 'open'
190 190 c.source = str2bool(request.GET.get('source'))
191 191 c.closed = str2bool(request.GET.get('closed'))
192 192 c.my = str2bool(request.GET.get('my'))
193 193 c.awaiting_review = str2bool(request.GET.get('awaiting_review'))
194 194 c.awaiting_my_review = str2bool(request.GET.get('awaiting_my_review'))
195 195 c.repo_name = repo_name
196 196
197 197 opened_by = None
198 198 if c.my:
199 199 c.active = 'my'
200 200 opened_by = [c.rhodecode_user.user_id]
201 201
202 202 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
203 203 if c.closed:
204 204 c.active = 'closed'
205 205 statuses = [PullRequest.STATUS_CLOSED]
206 206
207 207 if c.awaiting_review and not c.source:
208 208 c.active = 'awaiting'
209 209 if c.source and not c.awaiting_review:
210 210 c.active = 'source'
211 211 if c.awaiting_my_review:
212 212 c.active = 'awaiting_my'
213 213
214 214 data = self._get_pull_requests_list(
215 215 repo_name=repo_name, opened_by=opened_by, statuses=statuses)
216 216 if not request.is_xhr:
217 217 c.data = json.dumps(data['data'])
218 218 c.records_total = data['recordsTotal']
219 219 return render('/pullrequests/pullrequests.mako')
220 220 else:
221 221 return json.dumps(data)
222 222
223 223 def _get_pull_requests_list(self, repo_name, opened_by, statuses):
224 224 # pagination
225 225 start = safe_int(request.GET.get('start'), 0)
226 226 length = safe_int(request.GET.get('length'), c.visual.dashboard_items)
227 227 order_by, order_dir = self._extract_ordering(request)
228 228
229 229 if c.awaiting_review:
230 230 pull_requests = PullRequestModel().get_awaiting_review(
231 231 repo_name, source=c.source, opened_by=opened_by,
232 232 statuses=statuses, offset=start, length=length,
233 233 order_by=order_by, order_dir=order_dir)
234 234 pull_requests_total_count = PullRequestModel(
235 235 ).count_awaiting_review(
236 236 repo_name, source=c.source, statuses=statuses,
237 237 opened_by=opened_by)
238 238 elif c.awaiting_my_review:
239 239 pull_requests = PullRequestModel().get_awaiting_my_review(
240 240 repo_name, source=c.source, opened_by=opened_by,
241 241 user_id=c.rhodecode_user.user_id, statuses=statuses,
242 242 offset=start, length=length, order_by=order_by,
243 243 order_dir=order_dir)
244 244 pull_requests_total_count = PullRequestModel(
245 245 ).count_awaiting_my_review(
246 246 repo_name, source=c.source, user_id=c.rhodecode_user.user_id,
247 247 statuses=statuses, opened_by=opened_by)
248 248 else:
249 249 pull_requests = PullRequestModel().get_all(
250 250 repo_name, source=c.source, opened_by=opened_by,
251 251 statuses=statuses, offset=start, length=length,
252 252 order_by=order_by, order_dir=order_dir)
253 253 pull_requests_total_count = PullRequestModel().count_all(
254 254 repo_name, source=c.source, statuses=statuses,
255 255 opened_by=opened_by)
256 256
257 257 from rhodecode.lib.utils import PartialRenderer
258 258 _render = PartialRenderer('data_table/_dt_elements.mako')
259 259 data = []
260 260 for pr in pull_requests:
261 261 comments = CommentsModel().get_all_comments(
262 262 c.rhodecode_db_repo.repo_id, pull_request=pr)
263 263
264 264 data.append({
265 265 'name': _render('pullrequest_name',
266 266 pr.pull_request_id, pr.target_repo.repo_name),
267 267 'name_raw': pr.pull_request_id,
268 268 'status': _render('pullrequest_status',
269 269 pr.calculated_review_status()),
270 270 'title': _render(
271 271 'pullrequest_title', pr.title, pr.description),
272 272 'description': h.escape(pr.description),
273 273 'updated_on': _render('pullrequest_updated_on',
274 274 h.datetime_to_time(pr.updated_on)),
275 275 'updated_on_raw': h.datetime_to_time(pr.updated_on),
276 276 'created_on': _render('pullrequest_updated_on',
277 277 h.datetime_to_time(pr.created_on)),
278 278 'created_on_raw': h.datetime_to_time(pr.created_on),
279 279 'author': _render('pullrequest_author',
280 280 pr.author.full_contact, ),
281 281 'author_raw': pr.author.full_name,
282 282 'comments': _render('pullrequest_comments', len(comments)),
283 283 'comments_raw': len(comments),
284 284 'closed': pr.is_closed(),
285 285 })
286 286 # json used to render the grid
287 287 data = ({
288 288 'data': data,
289 289 'recordsTotal': pull_requests_total_count,
290 290 'recordsFiltered': pull_requests_total_count,
291 291 })
292 292 return data
293 293
294 294 @LoginRequired()
295 295 @NotAnonymous()
296 296 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
297 297 'repository.admin')
298 298 @HasAcceptedRepoType('git', 'hg')
299 299 def index(self):
300 300 source_repo = c.rhodecode_db_repo
301 301
302 302 try:
303 303 source_repo.scm_instance().get_commit()
304 304 except EmptyRepositoryError:
305 305 h.flash(h.literal(_('There are no commits yet')),
306 306 category='warning')
307 307 redirect(url('summary_home', repo_name=source_repo.repo_name))
308 308
309 309 commit_id = request.GET.get('commit')
310 310 branch_ref = request.GET.get('branch')
311 311 bookmark_ref = request.GET.get('bookmark')
312 312
313 313 try:
314 314 source_repo_data = PullRequestModel().generate_repo_data(
315 315 source_repo, commit_id=commit_id,
316 316 branch=branch_ref, bookmark=bookmark_ref)
317 317 except CommitDoesNotExistError as e:
318 318 log.exception(e)
319 319 h.flash(_('Commit does not exist'), 'error')
320 320 redirect(url('pullrequest_home', repo_name=source_repo.repo_name))
321 321
322 322 default_target_repo = source_repo
323 323
324 324 if source_repo.parent:
325 325 parent_vcs_obj = source_repo.parent.scm_instance()
326 326 if parent_vcs_obj and not parent_vcs_obj.is_empty():
327 327 # change default if we have a parent repo
328 328 default_target_repo = source_repo.parent
329 329
330 330 target_repo_data = PullRequestModel().generate_repo_data(
331 331 default_target_repo)
332 332
333 333 selected_source_ref = source_repo_data['refs']['selected_ref']
334 334
335 335 title_source_ref = selected_source_ref.split(':', 2)[1]
336 336 c.default_title = PullRequestModel().generate_pullrequest_title(
337 337 source=source_repo.repo_name,
338 338 source_ref=title_source_ref,
339 339 target=default_target_repo.repo_name
340 340 )
341 341
342 342 c.default_repo_data = {
343 343 'source_repo_name': source_repo.repo_name,
344 344 'source_refs_json': json.dumps(source_repo_data),
345 345 'target_repo_name': default_target_repo.repo_name,
346 346 'target_refs_json': json.dumps(target_repo_data),
347 347 }
348 348 c.default_source_ref = selected_source_ref
349 349
350 350 return render('/pullrequests/pullrequest.mako')
351 351
352 352 @LoginRequired()
353 353 @NotAnonymous()
354 354 @XHRRequired()
355 355 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
356 356 'repository.admin')
357 357 @jsonify
358 358 def get_repo_refs(self, repo_name, target_repo_name):
359 359 repo = Repository.get_by_repo_name(target_repo_name)
360 360 if not repo:
361 361 raise HTTPNotFound
362 362 return PullRequestModel().generate_repo_data(repo)
363 363
364 364 @LoginRequired()
365 365 @NotAnonymous()
366 366 @XHRRequired()
367 367 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
368 368 'repository.admin')
369 369 @jsonify
370 370 def get_repo_destinations(self, repo_name):
371 371 repo = Repository.get_by_repo_name(repo_name)
372 372 if not repo:
373 373 raise HTTPNotFound
374 374 filter_query = request.GET.get('query')
375 375
376 376 query = Repository.query() \
377 377 .order_by(func.length(Repository.repo_name)) \
378 378 .filter(or_(
379 379 Repository.repo_name == repo.repo_name,
380 380 Repository.fork_id == repo.repo_id))
381 381
382 382 if filter_query:
383 383 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
384 384 query = query.filter(
385 385 Repository.repo_name.ilike(ilike_expression))
386 386
387 387 add_parent = False
388 388 if repo.parent:
389 389 if filter_query in repo.parent.repo_name:
390 390 parent_vcs_obj = repo.parent.scm_instance()
391 391 if parent_vcs_obj and not parent_vcs_obj.is_empty():
392 392 add_parent = True
393 393
394 394 limit = 20 - 1 if add_parent else 20
395 395 all_repos = query.limit(limit).all()
396 396 if add_parent:
397 397 all_repos += [repo.parent]
398 398
399 399 repos = []
400 400 for obj in self.scm_model.get_repos(all_repos):
401 401 repos.append({
402 402 'id': obj['name'],
403 403 'text': obj['name'],
404 404 'type': 'repo',
405 405 'obj': obj['dbrepo']
406 406 })
407 407
408 408 data = {
409 409 'more': False,
410 410 'results': [{
411 411 'text': _('Repositories'),
412 412 'children': repos
413 413 }] if repos else []
414 414 }
415 415 return data
416 416
417 417 @LoginRequired()
418 418 @NotAnonymous()
419 419 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
420 420 'repository.admin')
421 421 @HasAcceptedRepoType('git', 'hg')
422 422 @auth.CSRFRequired()
423 423 def create(self, repo_name):
424 424 repo = Repository.get_by_repo_name(repo_name)
425 425 if not repo:
426 426 raise HTTPNotFound
427 427
428 428 controls = peppercorn.parse(request.POST.items())
429 429
430 430 try:
431 431 _form = PullRequestForm(repo.repo_id)().to_python(controls)
432 432 except formencode.Invalid as errors:
433 433 if errors.error_dict.get('revisions'):
434 434 msg = 'Revisions: %s' % errors.error_dict['revisions']
435 435 elif errors.error_dict.get('pullrequest_title'):
436 436 msg = _('Pull request requires a title with min. 3 chars')
437 437 else:
438 438 msg = _('Error creating pull request: {}').format(errors)
439 439 log.exception(msg)
440 440 h.flash(msg, 'error')
441 441
442 442 # would rather just go back to form ...
443 443 return redirect(url('pullrequest_home', repo_name=repo_name))
444 444
445 445 source_repo = _form['source_repo']
446 446 source_ref = _form['source_ref']
447 447 target_repo = _form['target_repo']
448 448 target_ref = _form['target_ref']
449 449 commit_ids = _form['revisions'][::-1]
450 450 reviewers = [
451 451 (r['user_id'], r['reasons']) for r in _form['review_members']]
452 452
453 453 # find the ancestor for this pr
454 454 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
455 455 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
456 456
457 457 source_scm = source_db_repo.scm_instance()
458 458 target_scm = target_db_repo.scm_instance()
459 459
460 460 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
461 461 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
462 462
463 463 ancestor = source_scm.get_common_ancestor(
464 464 source_commit.raw_id, target_commit.raw_id, target_scm)
465 465
466 466 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
467 467 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
468 468
469 469 pullrequest_title = _form['pullrequest_title']
470 470 title_source_ref = source_ref.split(':', 2)[1]
471 471 if not pullrequest_title:
472 472 pullrequest_title = PullRequestModel().generate_pullrequest_title(
473 473 source=source_repo,
474 474 source_ref=title_source_ref,
475 475 target=target_repo
476 476 )
477 477
478 478 description = _form['pullrequest_desc']
479 479 try:
480 480 pull_request = PullRequestModel().create(
481 481 c.rhodecode_user.user_id, source_repo, source_ref, target_repo,
482 482 target_ref, commit_ids, reviewers, pullrequest_title,
483 483 description
484 484 )
485 485 Session().commit()
486 486 h.flash(_('Successfully opened new pull request'),
487 487 category='success')
488 488 except Exception as e:
489 489 msg = _('Error occurred during sending pull request')
490 490 log.exception(msg)
491 491 h.flash(msg, category='error')
492 492 return redirect(url('pullrequest_home', repo_name=repo_name))
493 493
494 494 return redirect(url('pullrequest_show', repo_name=target_repo,
495 495 pull_request_id=pull_request.pull_request_id))
496 496
497 497 @LoginRequired()
498 498 @NotAnonymous()
499 499 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
500 500 'repository.admin')
501 501 @auth.CSRFRequired()
502 502 @jsonify
503 503 def update(self, repo_name, pull_request_id):
504 504 pull_request_id = safe_int(pull_request_id)
505 505 pull_request = PullRequest.get_or_404(pull_request_id)
506 506 # only owner or admin can update it
507 507 allowed_to_update = PullRequestModel().check_user_update(
508 508 pull_request, c.rhodecode_user)
509 509 if allowed_to_update:
510 510 controls = peppercorn.parse(request.POST.items())
511 511
512 512 if 'review_members' in controls:
513 513 self._update_reviewers(
514 514 pull_request_id, controls['review_members'])
515 515 elif str2bool(request.POST.get('update_commits', 'false')):
516 516 self._update_commits(pull_request)
517 517 elif str2bool(request.POST.get('close_pull_request', 'false')):
518 518 self._reject_close(pull_request)
519 519 elif str2bool(request.POST.get('edit_pull_request', 'false')):
520 520 self._edit_pull_request(pull_request)
521 521 else:
522 522 raise HTTPBadRequest()
523 523 return True
524 524 raise HTTPForbidden()
525 525
526 526 def _edit_pull_request(self, pull_request):
527 527 try:
528 528 PullRequestModel().edit(
529 529 pull_request, request.POST.get('title'),
530 530 request.POST.get('description'))
531 531 except ValueError:
532 532 msg = _(u'Cannot update closed pull requests.')
533 533 h.flash(msg, category='error')
534 534 return
535 535 else:
536 536 Session().commit()
537 537
538 538 msg = _(u'Pull request title & description updated.')
539 539 h.flash(msg, category='success')
540 540 return
541 541
542 542 def _update_commits(self, pull_request):
543 543 resp = PullRequestModel().update_commits(pull_request)
544 544
545 545 if resp.executed:
546 546 msg = _(
547 547 u'Pull request updated to "{source_commit_id}" with '
548 548 u'{count_added} added, {count_removed} removed commits.')
549 549 msg = msg.format(
550 550 source_commit_id=pull_request.source_ref_parts.commit_id,
551 551 count_added=len(resp.changes.added),
552 552 count_removed=len(resp.changes.removed))
553 553 h.flash(msg, category='success')
554 554
555 555 registry = get_current_registry()
556 556 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
557 557 channelstream_config = rhodecode_plugins.get('channelstream', {})
558 558 if channelstream_config.get('enabled'):
559 559 message = msg + (
560 560 ' - <a onclick="window.location.reload()">'
561 561 '<strong>{}</strong></a>'.format(_('Reload page')))
562 562 channel = '/repo${}$/pr/{}'.format(
563 563 pull_request.target_repo.repo_name,
564 564 pull_request.pull_request_id
565 565 )
566 566 payload = {
567 567 'type': 'message',
568 568 'user': 'system',
569 569 'exclude_users': [request.user.username],
570 570 'channel': channel,
571 571 'message': {
572 572 'message': message,
573 573 'level': 'success',
574 574 'topic': '/notifications'
575 575 }
576 576 }
577 577 channelstream_request(
578 578 channelstream_config, [payload], '/message',
579 579 raise_exc=False)
580 580 else:
581 581 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
582 582 warning_reasons = [
583 583 UpdateFailureReason.NO_CHANGE,
584 584 UpdateFailureReason.WRONG_REF_TPYE,
585 585 ]
586 586 category = 'warning' if resp.reason in warning_reasons else 'error'
587 587 h.flash(msg, category=category)
588 588
589 589 @auth.CSRFRequired()
590 590 @LoginRequired()
591 591 @NotAnonymous()
592 592 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
593 593 'repository.admin')
594 594 def merge(self, repo_name, pull_request_id):
595 595 """
596 596 POST /{repo_name}/pull-request/{pull_request_id}
597 597
598 598 Merge will perform a server-side merge of the specified
599 599 pull request, if the pull request is approved and mergeable.
600 After succesfull merging, the pull request is automatically
600 After successful merging, the pull request is automatically
601 601 closed, with a relevant comment.
602 602 """
603 603 pull_request_id = safe_int(pull_request_id)
604 604 pull_request = PullRequest.get_or_404(pull_request_id)
605 605 user = c.rhodecode_user
606 606
607 if self._meets_merge_pre_conditions(pull_request, user):
607 check = MergeCheck.validate(pull_request, user)
608 merge_possible = not check.failed
609
610 for err_type, error_msg in check.errors:
611 h.flash(error_msg, category=err_type)
612
613 if merge_possible:
608 614 log.debug("Pre-conditions checked, trying to merge.")
609 615 extras = vcs_operation_context(
610 616 request.environ, repo_name=pull_request.target_repo.repo_name,
611 617 username=user.username, action='push',
612 618 scm=pull_request.target_repo.repo_type)
613 619 self._merge_pull_request(pull_request, user, extras)
614 620
615 621 return redirect(url(
616 622 'pullrequest_show',
617 623 repo_name=pull_request.target_repo.repo_name,
618 624 pull_request_id=pull_request.pull_request_id))
619 625
620 def _meets_merge_pre_conditions(self, pull_request, user):
621 if not PullRequestModel().check_user_merge(pull_request, user):
622 raise HTTPForbidden()
623
624 merge_status, msg = PullRequestModel().merge_status(pull_request)
625 if not merge_status:
626 log.debug("Cannot merge, not mergeable.")
627 h.flash(msg, category='error')
628 return False
629
630 if (pull_request.calculated_review_status()
631 is not ChangesetStatus.STATUS_APPROVED):
632 log.debug("Cannot merge, approval is pending.")
633 msg = _('Pull request reviewer approval is pending.')
634 h.flash(msg, category='error')
635 return False
636
637 todos = CommentsModel().get_unresolved_todos(pull_request)
638 if todos:
639 log.debug("Cannot merge, unresolved todos left.")
640 if len(todos) == 1:
641 msg = _('Cannot merge, {} todo still not resolved.').format(
642 len(todos))
643 else:
644 msg = _('Cannot merge, {} todos still not resolved.').format(
645 len(todos))
646 h.flash(msg, category='error')
647 return False
648 return True
649
650 626 def _merge_pull_request(self, pull_request, user, extras):
651 627 merge_resp = PullRequestModel().merge(
652 628 pull_request, user, extras=extras)
653 629
654 630 if merge_resp.executed:
655 631 log.debug("The merge was successful, closing the pull request.")
656 632 PullRequestModel().close_pull_request(
657 633 pull_request.pull_request_id, user)
658 634 Session().commit()
659 635 msg = _('Pull request was successfully merged and closed.')
660 636 h.flash(msg, category='success')
661 637 else:
662 638 log.debug(
663 639 "The merge was not successful. Merge response: %s",
664 640 merge_resp)
665 641 msg = PullRequestModel().merge_status_message(
666 642 merge_resp.failure_reason)
667 643 h.flash(msg, category='error')
668 644
669 645 def _update_reviewers(self, pull_request_id, review_members):
670 646 reviewers = [
671 647 (int(r['user_id']), r['reasons']) for r in review_members]
672 648 PullRequestModel().update_reviewers(pull_request_id, reviewers)
673 649 Session().commit()
674 650
675 651 def _reject_close(self, pull_request):
676 652 if pull_request.is_closed():
677 653 raise HTTPForbidden()
678 654
679 655 PullRequestModel().close_pull_request_with_comment(
680 656 pull_request, c.rhodecode_user, c.rhodecode_db_repo)
681 657 Session().commit()
682 658
683 659 @LoginRequired()
684 660 @NotAnonymous()
685 661 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
686 662 'repository.admin')
687 663 @auth.CSRFRequired()
688 664 @jsonify
689 665 def delete(self, repo_name, pull_request_id):
690 666 pull_request_id = safe_int(pull_request_id)
691 667 pull_request = PullRequest.get_or_404(pull_request_id)
692 668 # only owner can delete it !
693 669 if pull_request.author.user_id == c.rhodecode_user.user_id:
694 670 PullRequestModel().delete(pull_request)
695 671 Session().commit()
696 672 h.flash(_('Successfully deleted pull request'),
697 673 category='success')
698 674 return redirect(url('my_account_pullrequests'))
699 675 raise HTTPForbidden()
700 676
701 677 def _get_pr_version(self, pull_request_id, version=None):
702 678 pull_request_id = safe_int(pull_request_id)
703 679 at_version = None
704 680
705 681 if version and version == 'latest':
706 682 pull_request_ver = PullRequest.get(pull_request_id)
707 683 pull_request_obj = pull_request_ver
708 684 _org_pull_request_obj = pull_request_obj
709 685 at_version = 'latest'
710 686 elif version:
711 687 pull_request_ver = PullRequestVersion.get_or_404(version)
712 688 pull_request_obj = pull_request_ver
713 689 _org_pull_request_obj = pull_request_ver.pull_request
714 690 at_version = pull_request_ver.pull_request_version_id
715 691 else:
716 692 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(pull_request_id)
717 693
718 694 pull_request_display_obj = PullRequest.get_pr_display_object(
719 695 pull_request_obj, _org_pull_request_obj)
720 696 return _org_pull_request_obj, pull_request_obj, \
721 697 pull_request_display_obj, at_version
722 698
723 699 def _get_pr_version_changes(self, version, pull_request_latest):
724 700 """
725 701 Generate changes commits, and diff data based on the current pr version
726 702 """
727 703
728 704 #TODO(marcink): save those changes as JSON metadata for chaching later.
729 705
730 706 # fake the version to add the "initial" state object
731 707 pull_request_initial = PullRequest.get_pr_display_object(
732 708 pull_request_latest, pull_request_latest,
733 709 internal_methods=['get_commit', 'versions'])
734 710 pull_request_initial.revisions = []
735 711 pull_request_initial.source_repo.get_commit = types.MethodType(
736 712 lambda *a, **k: EmptyCommit(), pull_request_initial)
737 713 pull_request_initial.source_repo.scm_instance = types.MethodType(
738 714 lambda *a, **k: EmptyRepository(), pull_request_initial)
739 715
740 716 _changes_versions = [pull_request_latest] + \
741 717 list(reversed(c.versions)) + \
742 718 [pull_request_initial]
743 719
744 720 if version == 'latest':
745 721 index = 0
746 722 else:
747 723 for pos, prver in enumerate(_changes_versions):
748 724 ver = getattr(prver, 'pull_request_version_id', -1)
749 725 if ver == safe_int(version):
750 726 index = pos
751 727 break
752 728 else:
753 729 index = 0
754 730
755 731 cur_obj = _changes_versions[index]
756 732 prev_obj = _changes_versions[index + 1]
757 733
758 734 old_commit_ids = set(prev_obj.revisions)
759 735 new_commit_ids = set(cur_obj.revisions)
760 736
761 737 changes = PullRequestModel()._calculate_commit_id_changes(
762 738 old_commit_ids, new_commit_ids)
763 739
764 740 old_diff_data, new_diff_data = PullRequestModel()._generate_update_diffs(
765 741 cur_obj, prev_obj)
766 742 file_changes = PullRequestModel()._calculate_file_changes(
767 743 old_diff_data, new_diff_data)
768 744 return changes, file_changes
769 745
770 746 @LoginRequired()
771 747 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
772 748 'repository.admin')
773 749 def show(self, repo_name, pull_request_id):
774 750 pull_request_id = safe_int(pull_request_id)
775 751 version = request.GET.get('version')
776 752 merge_checks = request.GET.get('merge_checks')
777 753
778 754 (pull_request_latest,
779 755 pull_request_at_ver,
780 756 pull_request_display_obj,
781 757 at_version) = self._get_pr_version(pull_request_id, version=version)
782 758
783 759 c.template_context['pull_request_data']['pull_request_id'] = \
784 760 pull_request_id
785 761
786 762 # pull_requests repo_name we opened it against
787 763 # ie. target_repo must match
788 764 if repo_name != pull_request_at_ver.target_repo.repo_name:
789 765 raise HTTPNotFound
790 766
791 767 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
792 768 pull_request_at_ver)
793 769
794 770 c.ancestor = None # TODO: add ancestor here
795 771 c.pull_request = pull_request_display_obj
796 772 c.pull_request_latest = pull_request_latest
797 773
798 774 pr_closed = pull_request_latest.is_closed()
799 775 if at_version and not at_version == 'latest':
800 776 c.allowed_to_change_status = False
801 777 c.allowed_to_update = False
802 778 c.allowed_to_merge = False
803 779 c.allowed_to_delete = False
804 780 c.allowed_to_comment = False
805 781 else:
806 782 c.allowed_to_change_status = PullRequestModel(). \
807 783 check_user_change_status(pull_request_at_ver, c.rhodecode_user)
808 784 c.allowed_to_update = PullRequestModel().check_user_update(
809 785 pull_request_latest, c.rhodecode_user) and not pr_closed
810 786 c.allowed_to_merge = PullRequestModel().check_user_merge(
811 787 pull_request_latest, c.rhodecode_user) and not pr_closed
812 788 c.allowed_to_delete = PullRequestModel().check_user_delete(
813 789 pull_request_latest, c.rhodecode_user) and not pr_closed
814 790 c.allowed_to_comment = not pr_closed
815 791
816 792 cc_model = CommentsModel()
817 793
818 794 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
819 795 c.pull_request_review_status = pull_request_at_ver.calculated_review_status()
820 796
821 797 c.versions = pull_request_display_obj.versions()
822 798 c.at_version = at_version
823 799 c.at_version_num = at_version if at_version and at_version != 'latest' else None
824 800 c.at_version_pos = ChangesetComment.get_index_from_version(
825 801 c.at_version_num, c.versions)
826 802
827 803 # GENERAL COMMENTS with versions #
828 804 q = cc_model._all_general_comments_of_pull_request(pull_request_latest)
829 805 general_comments = q.order_by(ChangesetComment.pull_request_version_id.asc())
830 806
831 807 # pick comments we want to render at current version
832 808 c.comment_versions = cc_model.aggregate_comments(
833 809 general_comments, c.versions, c.at_version_num)
834 810 c.comments = c.comment_versions[c.at_version_num]['until']
835 811
836 812 # INLINE COMMENTS with versions #
837 813 q = cc_model._all_inline_comments_of_pull_request(pull_request_latest)
838 814 inline_comments = q.order_by(ChangesetComment.pull_request_version_id.asc())
839 815 c.inline_versions = cc_model.aggregate_comments(
840 816 inline_comments, c.versions, c.at_version_num, inline=True)
841 817
842 818 # if we use version, then do not show later comments
843 819 # than current version
844 820 display_inline_comments = collections.defaultdict(lambda: collections.defaultdict(list))
845 821 for co in inline_comments:
846 822 if c.at_version_num:
847 823 # pick comments that are at least UPTO given version, so we
848 824 # don't render comments for higher version
849 825 should_render = co.pull_request_version_id and \
850 826 co.pull_request_version_id <= c.at_version_num
851 827 else:
852 828 # showing all, for 'latest'
853 829 should_render = True
854 830
855 831 if should_render:
856 832 display_inline_comments[co.f_path][co.line_no].append(co)
857 833
858 c.pr_merge_checks = []
859 c.pr_merge_status, c.pr_merge_msg = PullRequestModel().merge_status(
860 pull_request_at_ver)
861 c.pr_merge_checks.append(['warning' if not c.pr_merge_status else 'success', c.pr_merge_msg])
862
863 if c.pull_request_review_status != ChangesetStatus.STATUS_APPROVED:
864 approval_msg = _('Reviewer approval is pending.')
865 c.pr_merge_status = False
866 c.pr_merge_checks.append(['warning', approval_msg])
867
868 todos = cc_model.get_unresolved_todos(pull_request_latest)
869 if todos:
870 c.pr_merge_status = False
871 if len(todos) == 1:
872 msg = _('{} todo still not resolved.').format(len(todos))
873 else:
874 msg = _('{} todos still not resolved.').format(len(todos))
875 c.pr_merge_checks.append(['warning', msg])
834 _merge_check = MergeCheck.validate(
835 pull_request_latest, user=c.rhodecode_user)
836 c.pr_merge_errors = _merge_check.errors
837 c.pr_merge_possible = not _merge_check.failed
838 c.pr_merge_message = _merge_check.merge_msg
876 839
877 840 if merge_checks:
878 841 return render('/pullrequests/pullrequest_merge_checks.mako')
879 842
880 843 # load compare data into template context
881 844 self._load_compare_data(pull_request_at_ver, display_inline_comments)
882 845
883 846 # this is a hack to properly display links, when creating PR, the
884 847 # compare view and others uses different notation, and
885 848 # compare_commits.mako renders links based on the target_repo.
886 849 # We need to swap that here to generate it properly on the html side
887 850 c.target_repo = c.source_repo
888 851
889 852 if c.allowed_to_update:
890 853 force_close = ('forced_closed', _('Close Pull Request'))
891 854 statuses = ChangesetStatus.STATUSES + [force_close]
892 855 else:
893 856 statuses = ChangesetStatus.STATUSES
894 857 c.commit_statuses = statuses
895 858
896 859 c.changes = None
897 860 c.file_changes = None
898 861
899 862 c.show_version_changes = 1 # control flag, not used yet
900 863
901 864 if at_version and c.show_version_changes:
902 865 c.changes, c.file_changes = self._get_pr_version_changes(
903 866 version, pull_request_latest)
904 867
905 868 return render('/pullrequests/pullrequest_show.mako')
906 869
907 870 @LoginRequired()
908 871 @NotAnonymous()
909 872 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
910 873 'repository.admin')
911 874 @auth.CSRFRequired()
912 875 @jsonify
913 876 def comment(self, repo_name, pull_request_id):
914 877 pull_request_id = safe_int(pull_request_id)
915 878 pull_request = PullRequest.get_or_404(pull_request_id)
916 879 if pull_request.is_closed():
917 880 raise HTTPForbidden()
918 881
919 882 # TODO: johbo: Re-think this bit, "approved_closed" does not exist
920 883 # as a changeset status, still we want to send it in one value.
921 884 status = request.POST.get('changeset_status', None)
922 885 text = request.POST.get('text')
923 886 comment_type = request.POST.get('comment_type')
924 887 resolves_comment_id = request.POST.get('resolves_comment_id', None)
925 888
926 889 if status and '_closed' in status:
927 890 close_pr = True
928 891 status = status.replace('_closed', '')
929 892 else:
930 893 close_pr = False
931 894
932 895 forced = (status == 'forced')
933 896 if forced:
934 897 status = 'rejected'
935 898
936 899 allowed_to_change_status = PullRequestModel().check_user_change_status(
937 900 pull_request, c.rhodecode_user)
938 901
939 902 if status and allowed_to_change_status:
940 903 message = (_('Status change %(transition_icon)s %(status)s')
941 904 % {'transition_icon': '>',
942 905 'status': ChangesetStatus.get_status_lbl(status)})
943 906 if close_pr:
944 907 message = _('Closing with') + ' ' + message
945 908 text = text or message
946 909 comm = CommentsModel().create(
947 910 text=text,
948 911 repo=c.rhodecode_db_repo.repo_id,
949 912 user=c.rhodecode_user.user_id,
950 913 pull_request=pull_request_id,
951 914 f_path=request.POST.get('f_path'),
952 915 line_no=request.POST.get('line'),
953 916 status_change=(ChangesetStatus.get_status_lbl(status)
954 917 if status and allowed_to_change_status else None),
955 918 status_change_type=(status
956 919 if status and allowed_to_change_status else None),
957 920 closing_pr=close_pr,
958 921 comment_type=comment_type,
959 922 resolves_comment_id=resolves_comment_id
960 923 )
961 924
962 925 if allowed_to_change_status:
963 926 old_calculated_status = pull_request.calculated_review_status()
964 927 # get status if set !
965 928 if status:
966 929 ChangesetStatusModel().set_status(
967 930 c.rhodecode_db_repo.repo_id,
968 931 status,
969 932 c.rhodecode_user.user_id,
970 933 comm,
971 934 pull_request=pull_request_id
972 935 )
973 936
974 937 Session().flush()
975 938 events.trigger(events.PullRequestCommentEvent(pull_request, comm))
976 939 # we now calculate the status of pull request, and based on that
977 940 # calculation we set the commits status
978 941 calculated_status = pull_request.calculated_review_status()
979 942 if old_calculated_status != calculated_status:
980 943 PullRequestModel()._trigger_pull_request_hook(
981 944 pull_request, c.rhodecode_user, 'review_status_change')
982 945
983 946 calculated_status_lbl = ChangesetStatus.get_status_lbl(
984 947 calculated_status)
985 948
986 949 if close_pr:
987 950 status_completed = (
988 951 calculated_status in [ChangesetStatus.STATUS_APPROVED,
989 952 ChangesetStatus.STATUS_REJECTED])
990 953 if forced or status_completed:
991 954 PullRequestModel().close_pull_request(
992 955 pull_request_id, c.rhodecode_user)
993 956 else:
994 957 h.flash(_('Closing pull request on other statuses than '
995 958 'rejected or approved is forbidden. '
996 959 'Calculated status from all reviewers '
997 960 'is currently: %s') % calculated_status_lbl,
998 961 category='warning')
999 962
1000 963 Session().commit()
1001 964
1002 965 if not request.is_xhr:
1003 966 return redirect(h.url('pullrequest_show', repo_name=repo_name,
1004 967 pull_request_id=pull_request_id))
1005 968
1006 969 data = {
1007 970 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
1008 971 }
1009 972 if comm:
1010 973 c.co = comm
1011 974 c.inline_comment = True if comm.line_no else False
1012 975 data.update(comm.get_dict())
1013 976 data.update({'rendered_text':
1014 977 render('changeset/changeset_comment_block.mako')})
1015 978
1016 979 return data
1017 980
1018 981 @LoginRequired()
1019 982 @NotAnonymous()
1020 983 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
1021 984 'repository.admin')
1022 985 @auth.CSRFRequired()
1023 986 @jsonify
1024 987 def delete_comment(self, repo_name, comment_id):
1025 988 return self._delete_comment(comment_id)
1026 989
1027 990 def _delete_comment(self, comment_id):
1028 991 comment_id = safe_int(comment_id)
1029 992 co = ChangesetComment.get_or_404(comment_id)
1030 993 if co.pull_request.is_closed():
1031 994 # don't allow deleting comments on closed pull request
1032 995 raise HTTPForbidden()
1033 996
1034 997 is_owner = co.author.user_id == c.rhodecode_user.user_id
1035 998 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
1036 999 if h.HasPermissionAny('hg.admin')() or is_repo_admin or is_owner:
1037 1000 old_calculated_status = co.pull_request.calculated_review_status()
1038 1001 CommentsModel().delete(comment=co)
1039 1002 Session().commit()
1040 1003 calculated_status = co.pull_request.calculated_review_status()
1041 1004 if old_calculated_status != calculated_status:
1042 1005 PullRequestModel()._trigger_pull_request_hook(
1043 1006 co.pull_request, c.rhodecode_user, 'review_status_change')
1044 1007 return True
1045 1008 else:
1046 1009 raise HTTPForbidden()
@@ -1,1318 +1,1398 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 """
23 23 pull request model for RhodeCode
24 24 """
25 25
26 26 from collections import namedtuple
27 27 import json
28 28 import logging
29 29 import datetime
30 30 import urllib
31 31
32 32 from pylons.i18n.translation import _
33 33 from pylons.i18n.translation import lazy_ugettext
34 34 from sqlalchemy import or_
35 35
36 36 from rhodecode.lib import helpers as h, hooks_utils, diffs
37 37 from rhodecode.lib.compat import OrderedDict
38 38 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
39 39 from rhodecode.lib.markup_renderer import (
40 40 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
41 41 from rhodecode.lib.utils import action_logger
42 42 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
43 43 from rhodecode.lib.vcs.backends.base import (
44 44 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
45 45 from rhodecode.lib.vcs.conf import settings as vcs_settings
46 46 from rhodecode.lib.vcs.exceptions import (
47 47 CommitDoesNotExistError, EmptyRepositoryError)
48 48 from rhodecode.model import BaseModel
49 49 from rhodecode.model.changeset_status import ChangesetStatusModel
50 50 from rhodecode.model.comment import CommentsModel
51 51 from rhodecode.model.db import (
52 52 PullRequest, PullRequestReviewers, ChangesetStatus,
53 53 PullRequestVersion, ChangesetComment)
54 54 from rhodecode.model.meta import Session
55 55 from rhodecode.model.notification import NotificationModel, \
56 56 EmailNotificationModel
57 57 from rhodecode.model.scm import ScmModel
58 58 from rhodecode.model.settings import VcsSettingsModel
59 59
60 60
61 61 log = logging.getLogger(__name__)
62 62
63 63
64 64 # Data structure to hold the response data when updating commits during a pull
65 65 # request update.
66 66 UpdateResponse = namedtuple(
67 67 'UpdateResponse', 'executed, reason, new, old, changes')
68 68
69 69
70 70 class PullRequestModel(BaseModel):
71 71
72 72 cls = PullRequest
73 73
74 74 DIFF_CONTEXT = 3
75 75
76 76 MERGE_STATUS_MESSAGES = {
77 77 MergeFailureReason.NONE: lazy_ugettext(
78 78 'This pull request can be automatically merged.'),
79 79 MergeFailureReason.UNKNOWN: lazy_ugettext(
80 80 'This pull request cannot be merged because of an unhandled'
81 81 ' exception.'),
82 82 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
83 83 'This pull request cannot be merged because of conflicts.'),
84 84 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
85 85 'This pull request could not be merged because push to target'
86 86 ' failed.'),
87 87 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
88 88 'This pull request cannot be merged because the target is not a'
89 89 ' head.'),
90 90 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
91 91 'This pull request cannot be merged because the source contains'
92 92 ' more branches than the target.'),
93 93 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
94 94 'This pull request cannot be merged because the target has'
95 95 ' multiple heads.'),
96 96 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
97 97 'This pull request cannot be merged because the target repository'
98 98 ' is locked.'),
99 99 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
100 100 'This pull request cannot be merged because the target or the '
101 101 'source reference is missing.'),
102 102 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
103 103 'This pull request cannot be merged because the target '
104 104 'reference is missing.'),
105 105 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
106 106 'This pull request cannot be merged because the source '
107 107 'reference is missing.'),
108 108 MergeFailureReason.SUBREPO_MERGE_FAILED: lazy_ugettext(
109 109 'This pull request cannot be merged because of conflicts related '
110 110 'to sub repositories.'),
111 111 }
112 112
113 113 UPDATE_STATUS_MESSAGES = {
114 114 UpdateFailureReason.NONE: lazy_ugettext(
115 115 'Pull request update successful.'),
116 116 UpdateFailureReason.UNKNOWN: lazy_ugettext(
117 117 'Pull request update failed because of an unknown error.'),
118 118 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
119 119 'No update needed because the source reference is already '
120 120 'up to date.'),
121 121 UpdateFailureReason.WRONG_REF_TPYE: lazy_ugettext(
122 122 'Pull request cannot be updated because the reference type is '
123 123 'not supported for an update.'),
124 124 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
125 125 'This pull request cannot be updated because the target '
126 126 'reference is missing.'),
127 127 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
128 128 'This pull request cannot be updated because the source '
129 129 'reference is missing.'),
130 130 }
131 131
132 132 def __get_pull_request(self, pull_request):
133 133 return self._get_instance((
134 134 PullRequest, PullRequestVersion), pull_request)
135 135
136 136 def _check_perms(self, perms, pull_request, user, api=False):
137 137 if not api:
138 138 return h.HasRepoPermissionAny(*perms)(
139 139 user=user, repo_name=pull_request.target_repo.repo_name)
140 140 else:
141 141 return h.HasRepoPermissionAnyApi(*perms)(
142 142 user=user, repo_name=pull_request.target_repo.repo_name)
143 143
144 144 def check_user_read(self, pull_request, user, api=False):
145 145 _perms = ('repository.admin', 'repository.write', 'repository.read',)
146 146 return self._check_perms(_perms, pull_request, user, api)
147 147
148 148 def check_user_merge(self, pull_request, user, api=False):
149 149 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
150 150 return self._check_perms(_perms, pull_request, user, api)
151 151
152 152 def check_user_update(self, pull_request, user, api=False):
153 153 owner = user.user_id == pull_request.user_id
154 154 return self.check_user_merge(pull_request, user, api) or owner
155 155
156 156 def check_user_delete(self, pull_request, user):
157 157 owner = user.user_id == pull_request.user_id
158 158 _perms = ('repository.admin')
159 159 return self._check_perms(_perms, pull_request, user) or owner
160 160
161 161 def check_user_change_status(self, pull_request, user, api=False):
162 162 reviewer = user.user_id in [x.user_id for x in
163 163 pull_request.reviewers]
164 164 return self.check_user_update(pull_request, user, api) or reviewer
165 165
166 166 def get(self, pull_request):
167 167 return self.__get_pull_request(pull_request)
168 168
169 169 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
170 170 opened_by=None, order_by=None,
171 171 order_dir='desc'):
172 172 repo = None
173 173 if repo_name:
174 174 repo = self._get_repo(repo_name)
175 175
176 176 q = PullRequest.query()
177 177
178 178 # source or target
179 179 if repo and source:
180 180 q = q.filter(PullRequest.source_repo == repo)
181 181 elif repo:
182 182 q = q.filter(PullRequest.target_repo == repo)
183 183
184 184 # closed,opened
185 185 if statuses:
186 186 q = q.filter(PullRequest.status.in_(statuses))
187 187
188 188 # opened by filter
189 189 if opened_by:
190 190 q = q.filter(PullRequest.user_id.in_(opened_by))
191 191
192 192 if order_by:
193 193 order_map = {
194 194 'name_raw': PullRequest.pull_request_id,
195 195 'title': PullRequest.title,
196 196 'updated_on_raw': PullRequest.updated_on,
197 197 'target_repo': PullRequest.target_repo_id
198 198 }
199 199 if order_dir == 'asc':
200 200 q = q.order_by(order_map[order_by].asc())
201 201 else:
202 202 q = q.order_by(order_map[order_by].desc())
203 203
204 204 return q
205 205
206 206 def count_all(self, repo_name, source=False, statuses=None,
207 207 opened_by=None):
208 208 """
209 209 Count the number of pull requests for a specific repository.
210 210
211 211 :param repo_name: target or source repo
212 212 :param source: boolean flag to specify if repo_name refers to source
213 213 :param statuses: list of pull request statuses
214 214 :param opened_by: author user of the pull request
215 215 :returns: int number of pull requests
216 216 """
217 217 q = self._prepare_get_all_query(
218 218 repo_name, source=source, statuses=statuses, opened_by=opened_by)
219 219
220 220 return q.count()
221 221
222 222 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
223 223 offset=0, length=None, order_by=None, order_dir='desc'):
224 224 """
225 225 Get all pull requests for a specific repository.
226 226
227 227 :param repo_name: target or source repo
228 228 :param source: boolean flag to specify if repo_name refers to source
229 229 :param statuses: list of pull request statuses
230 230 :param opened_by: author user of the pull request
231 231 :param offset: pagination offset
232 232 :param length: length of returned list
233 233 :param order_by: order of the returned list
234 234 :param order_dir: 'asc' or 'desc' ordering direction
235 235 :returns: list of pull requests
236 236 """
237 237 q = self._prepare_get_all_query(
238 238 repo_name, source=source, statuses=statuses, opened_by=opened_by,
239 239 order_by=order_by, order_dir=order_dir)
240 240
241 241 if length:
242 242 pull_requests = q.limit(length).offset(offset).all()
243 243 else:
244 244 pull_requests = q.all()
245 245
246 246 return pull_requests
247 247
248 248 def count_awaiting_review(self, repo_name, source=False, statuses=None,
249 249 opened_by=None):
250 250 """
251 251 Count the number of pull requests for a specific repository that are
252 252 awaiting review.
253 253
254 254 :param repo_name: target or source repo
255 255 :param source: boolean flag to specify if repo_name refers to source
256 256 :param statuses: list of pull request statuses
257 257 :param opened_by: author user of the pull request
258 258 :returns: int number of pull requests
259 259 """
260 260 pull_requests = self.get_awaiting_review(
261 261 repo_name, source=source, statuses=statuses, opened_by=opened_by)
262 262
263 263 return len(pull_requests)
264 264
265 265 def get_awaiting_review(self, repo_name, source=False, statuses=None,
266 266 opened_by=None, offset=0, length=None,
267 267 order_by=None, order_dir='desc'):
268 268 """
269 269 Get all pull requests for a specific repository that are awaiting
270 270 review.
271 271
272 272 :param repo_name: target or source repo
273 273 :param source: boolean flag to specify if repo_name refers to source
274 274 :param statuses: list of pull request statuses
275 275 :param opened_by: author user of the pull request
276 276 :param offset: pagination offset
277 277 :param length: length of returned list
278 278 :param order_by: order of the returned list
279 279 :param order_dir: 'asc' or 'desc' ordering direction
280 280 :returns: list of pull requests
281 281 """
282 282 pull_requests = self.get_all(
283 283 repo_name, source=source, statuses=statuses, opened_by=opened_by,
284 284 order_by=order_by, order_dir=order_dir)
285 285
286 286 _filtered_pull_requests = []
287 287 for pr in pull_requests:
288 288 status = pr.calculated_review_status()
289 289 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
290 290 ChangesetStatus.STATUS_UNDER_REVIEW]:
291 291 _filtered_pull_requests.append(pr)
292 292 if length:
293 293 return _filtered_pull_requests[offset:offset+length]
294 294 else:
295 295 return _filtered_pull_requests
296 296
297 297 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
298 298 opened_by=None, user_id=None):
299 299 """
300 300 Count the number of pull requests for a specific repository that are
301 301 awaiting review from a specific user.
302 302
303 303 :param repo_name: target or source repo
304 304 :param source: boolean flag to specify if repo_name refers to source
305 305 :param statuses: list of pull request statuses
306 306 :param opened_by: author user of the pull request
307 307 :param user_id: reviewer user of the pull request
308 308 :returns: int number of pull requests
309 309 """
310 310 pull_requests = self.get_awaiting_my_review(
311 311 repo_name, source=source, statuses=statuses, opened_by=opened_by,
312 312 user_id=user_id)
313 313
314 314 return len(pull_requests)
315 315
316 316 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
317 317 opened_by=None, user_id=None, offset=0,
318 318 length=None, order_by=None, order_dir='desc'):
319 319 """
320 320 Get all pull requests for a specific repository that are awaiting
321 321 review from a specific user.
322 322
323 323 :param repo_name: target or source repo
324 324 :param source: boolean flag to specify if repo_name refers to source
325 325 :param statuses: list of pull request statuses
326 326 :param opened_by: author user of the pull request
327 327 :param user_id: reviewer user of the pull request
328 328 :param offset: pagination offset
329 329 :param length: length of returned list
330 330 :param order_by: order of the returned list
331 331 :param order_dir: 'asc' or 'desc' ordering direction
332 332 :returns: list of pull requests
333 333 """
334 334 pull_requests = self.get_all(
335 335 repo_name, source=source, statuses=statuses, opened_by=opened_by,
336 336 order_by=order_by, order_dir=order_dir)
337 337
338 338 _my = PullRequestModel().get_not_reviewed(user_id)
339 339 my_participation = []
340 340 for pr in pull_requests:
341 341 if pr in _my:
342 342 my_participation.append(pr)
343 343 _filtered_pull_requests = my_participation
344 344 if length:
345 345 return _filtered_pull_requests[offset:offset+length]
346 346 else:
347 347 return _filtered_pull_requests
348 348
349 349 def get_not_reviewed(self, user_id):
350 350 return [
351 351 x.pull_request for x in PullRequestReviewers.query().filter(
352 352 PullRequestReviewers.user_id == user_id).all()
353 353 ]
354 354
355 355 def _prepare_participating_query(self, user_id=None, statuses=None,
356 356 order_by=None, order_dir='desc'):
357 357 q = PullRequest.query()
358 358 if user_id:
359 359 reviewers_subquery = Session().query(
360 360 PullRequestReviewers.pull_request_id).filter(
361 361 PullRequestReviewers.user_id == user_id).subquery()
362 362 user_filter= or_(
363 363 PullRequest.user_id == user_id,
364 364 PullRequest.pull_request_id.in_(reviewers_subquery)
365 365 )
366 366 q = PullRequest.query().filter(user_filter)
367 367
368 368 # closed,opened
369 369 if statuses:
370 370 q = q.filter(PullRequest.status.in_(statuses))
371 371
372 372 if order_by:
373 373 order_map = {
374 374 'name_raw': PullRequest.pull_request_id,
375 375 'title': PullRequest.title,
376 376 'updated_on_raw': PullRequest.updated_on,
377 377 'target_repo': PullRequest.target_repo_id
378 378 }
379 379 if order_dir == 'asc':
380 380 q = q.order_by(order_map[order_by].asc())
381 381 else:
382 382 q = q.order_by(order_map[order_by].desc())
383 383
384 384 return q
385 385
386 386 def count_im_participating_in(self, user_id=None, statuses=None):
387 387 q = self._prepare_participating_query(user_id, statuses=statuses)
388 388 return q.count()
389 389
390 390 def get_im_participating_in(
391 391 self, user_id=None, statuses=None, offset=0,
392 392 length=None, order_by=None, order_dir='desc'):
393 393 """
394 394 Get all Pull requests that i'm participating in, or i have opened
395 395 """
396 396
397 397 q = self._prepare_participating_query(
398 398 user_id, statuses=statuses, order_by=order_by,
399 399 order_dir=order_dir)
400 400
401 401 if length:
402 402 pull_requests = q.limit(length).offset(offset).all()
403 403 else:
404 404 pull_requests = q.all()
405 405
406 406 return pull_requests
407 407
408 408 def get_versions(self, pull_request):
409 409 """
410 410 returns version of pull request sorted by ID descending
411 411 """
412 412 return PullRequestVersion.query()\
413 413 .filter(PullRequestVersion.pull_request == pull_request)\
414 414 .order_by(PullRequestVersion.pull_request_version_id.asc())\
415 415 .all()
416 416
417 417 def create(self, created_by, source_repo, source_ref, target_repo,
418 418 target_ref, revisions, reviewers, title, description=None):
419 419 created_by_user = self._get_user(created_by)
420 420 source_repo = self._get_repo(source_repo)
421 421 target_repo = self._get_repo(target_repo)
422 422
423 423 pull_request = PullRequest()
424 424 pull_request.source_repo = source_repo
425 425 pull_request.source_ref = source_ref
426 426 pull_request.target_repo = target_repo
427 427 pull_request.target_ref = target_ref
428 428 pull_request.revisions = revisions
429 429 pull_request.title = title
430 430 pull_request.description = description
431 431 pull_request.author = created_by_user
432 432
433 433 Session().add(pull_request)
434 434 Session().flush()
435 435
436 436 reviewer_ids = set()
437 437 # members / reviewers
438 438 for reviewer_object in reviewers:
439 439 if isinstance(reviewer_object, tuple):
440 440 user_id, reasons = reviewer_object
441 441 else:
442 442 user_id, reasons = reviewer_object, []
443 443
444 444 user = self._get_user(user_id)
445 445 reviewer_ids.add(user.user_id)
446 446
447 447 reviewer = PullRequestReviewers(user, pull_request, reasons)
448 448 Session().add(reviewer)
449 449
450 450 # Set approval status to "Under Review" for all commits which are
451 451 # part of this pull request.
452 452 ChangesetStatusModel().set_status(
453 453 repo=target_repo,
454 454 status=ChangesetStatus.STATUS_UNDER_REVIEW,
455 455 user=created_by_user,
456 456 pull_request=pull_request
457 457 )
458 458
459 459 self.notify_reviewers(pull_request, reviewer_ids)
460 460 self._trigger_pull_request_hook(
461 461 pull_request, created_by_user, 'create')
462 462
463 463 return pull_request
464 464
465 465 def _trigger_pull_request_hook(self, pull_request, user, action):
466 466 pull_request = self.__get_pull_request(pull_request)
467 467 target_scm = pull_request.target_repo.scm_instance()
468 468 if action == 'create':
469 469 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
470 470 elif action == 'merge':
471 471 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
472 472 elif action == 'close':
473 473 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
474 474 elif action == 'review_status_change':
475 475 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
476 476 elif action == 'update':
477 477 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
478 478 else:
479 479 return
480 480
481 481 trigger_hook(
482 482 username=user.username,
483 483 repo_name=pull_request.target_repo.repo_name,
484 484 repo_alias=target_scm.alias,
485 485 pull_request=pull_request)
486 486
487 487 def _get_commit_ids(self, pull_request):
488 488 """
489 489 Return the commit ids of the merged pull request.
490 490
491 491 This method is not dealing correctly yet with the lack of autoupdates
492 492 nor with the implicit target updates.
493 493 For example: if a commit in the source repo is already in the target it
494 494 will be reported anyways.
495 495 """
496 496 merge_rev = pull_request.merge_rev
497 497 if merge_rev is None:
498 498 raise ValueError('This pull request was not merged yet')
499 499
500 500 commit_ids = list(pull_request.revisions)
501 501 if merge_rev not in commit_ids:
502 502 commit_ids.append(merge_rev)
503 503
504 504 return commit_ids
505 505
506 506 def merge(self, pull_request, user, extras):
507 507 log.debug("Merging pull request %s", pull_request.pull_request_id)
508 508 merge_state = self._merge_pull_request(pull_request, user, extras)
509 509 if merge_state.executed:
510 510 log.debug(
511 511 "Merge was successful, updating the pull request comments.")
512 512 self._comment_and_close_pr(pull_request, user, merge_state)
513 513 self._log_action('user_merged_pull_request', user, pull_request)
514 514 else:
515 515 log.warn("Merge failed, not updating the pull request.")
516 516 return merge_state
517 517
518 518 def _merge_pull_request(self, pull_request, user, extras):
519 519 target_vcs = pull_request.target_repo.scm_instance()
520 520 source_vcs = pull_request.source_repo.scm_instance()
521 521 target_ref = self._refresh_reference(
522 522 pull_request.target_ref_parts, target_vcs)
523 523
524 524 message = _(
525 525 'Merge pull request #%(pr_id)s from '
526 526 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
527 527 'pr_id': pull_request.pull_request_id,
528 528 'source_repo': source_vcs.name,
529 529 'source_ref_name': pull_request.source_ref_parts.name,
530 530 'pr_title': pull_request.title
531 531 }
532 532
533 533 workspace_id = self._workspace_id(pull_request)
534 534 use_rebase = self._use_rebase_for_merging(pull_request)
535 535
536 536 callback_daemon, extras = prepare_callback_daemon(
537 537 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
538 538 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
539 539
540 540 with callback_daemon:
541 541 # TODO: johbo: Implement a clean way to run a config_override
542 542 # for a single call.
543 543 target_vcs.config.set(
544 544 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
545 545 merge_state = target_vcs.merge(
546 546 target_ref, source_vcs, pull_request.source_ref_parts,
547 547 workspace_id, user_name=user.username,
548 548 user_email=user.email, message=message, use_rebase=use_rebase)
549 549 return merge_state
550 550
551 551 def _comment_and_close_pr(self, pull_request, user, merge_state):
552 552 pull_request.merge_rev = merge_state.merge_ref.commit_id
553 553 pull_request.updated_on = datetime.datetime.now()
554 554
555 555 CommentsModel().create(
556 556 text=unicode(_('Pull request merged and closed')),
557 557 repo=pull_request.target_repo.repo_id,
558 558 user=user.user_id,
559 559 pull_request=pull_request.pull_request_id,
560 560 f_path=None,
561 561 line_no=None,
562 562 closing_pr=True
563 563 )
564 564
565 565 Session().add(pull_request)
566 566 Session().flush()
567 567 # TODO: paris: replace invalidation with less radical solution
568 568 ScmModel().mark_for_invalidation(
569 569 pull_request.target_repo.repo_name)
570 570 self._trigger_pull_request_hook(pull_request, user, 'merge')
571 571
572 572 def has_valid_update_type(self, pull_request):
573 573 source_ref_type = pull_request.source_ref_parts.type
574 574 return source_ref_type in ['book', 'branch', 'tag']
575 575
576 576 def update_commits(self, pull_request):
577 577 """
578 578 Get the updated list of commits for the pull request
579 579 and return the new pull request version and the list
580 580 of commits processed by this update action
581 581 """
582 582 pull_request = self.__get_pull_request(pull_request)
583 583 source_ref_type = pull_request.source_ref_parts.type
584 584 source_ref_name = pull_request.source_ref_parts.name
585 585 source_ref_id = pull_request.source_ref_parts.commit_id
586 586
587 587 if not self.has_valid_update_type(pull_request):
588 588 log.debug(
589 589 "Skipping update of pull request %s due to ref type: %s",
590 590 pull_request, source_ref_type)
591 591 return UpdateResponse(
592 592 executed=False,
593 593 reason=UpdateFailureReason.WRONG_REF_TPYE,
594 594 old=pull_request, new=None, changes=None)
595 595
596 596 source_repo = pull_request.source_repo.scm_instance()
597 597 try:
598 598 source_commit = source_repo.get_commit(commit_id=source_ref_name)
599 599 except CommitDoesNotExistError:
600 600 return UpdateResponse(
601 601 executed=False,
602 602 reason=UpdateFailureReason.MISSING_SOURCE_REF,
603 603 old=pull_request, new=None, changes=None)
604 604
605 605 if source_ref_id == source_commit.raw_id:
606 606 log.debug("Nothing changed in pull request %s", pull_request)
607 607 return UpdateResponse(
608 608 executed=False,
609 609 reason=UpdateFailureReason.NO_CHANGE,
610 610 old=pull_request, new=None, changes=None)
611 611
612 612 # Finally there is a need for an update
613 613 pull_request_version = self._create_version_from_snapshot(pull_request)
614 614 self._link_comments_to_version(pull_request_version)
615 615
616 616 target_ref_type = pull_request.target_ref_parts.type
617 617 target_ref_name = pull_request.target_ref_parts.name
618 618 target_ref_id = pull_request.target_ref_parts.commit_id
619 619 target_repo = pull_request.target_repo.scm_instance()
620 620
621 621 try:
622 622 if target_ref_type in ('tag', 'branch', 'book'):
623 623 target_commit = target_repo.get_commit(target_ref_name)
624 624 else:
625 625 target_commit = target_repo.get_commit(target_ref_id)
626 626 except CommitDoesNotExistError:
627 627 return UpdateResponse(
628 628 executed=False,
629 629 reason=UpdateFailureReason.MISSING_TARGET_REF,
630 630 old=pull_request, new=None, changes=None)
631 631
632 632 # re-compute commit ids
633 633 old_commit_ids = set(pull_request.revisions)
634 634 pre_load = ["author", "branch", "date", "message"]
635 635 commit_ranges = target_repo.compare(
636 636 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
637 637 pre_load=pre_load)
638 638
639 639 ancestor = target_repo.get_common_ancestor(
640 640 target_commit.raw_id, source_commit.raw_id, source_repo)
641 641
642 642 pull_request.source_ref = '%s:%s:%s' % (
643 643 source_ref_type, source_ref_name, source_commit.raw_id)
644 644 pull_request.target_ref = '%s:%s:%s' % (
645 645 target_ref_type, target_ref_name, ancestor)
646 646 pull_request.revisions = [
647 647 commit.raw_id for commit in reversed(commit_ranges)]
648 648 pull_request.updated_on = datetime.datetime.now()
649 649 Session().add(pull_request)
650 650 new_commit_ids = set(pull_request.revisions)
651 651
652 652 changes = self._calculate_commit_id_changes(
653 653 old_commit_ids, new_commit_ids)
654 654
655 655 old_diff_data, new_diff_data = self._generate_update_diffs(
656 656 pull_request, pull_request_version)
657 657
658 658 CommentsModel().outdate_comments(
659 659 pull_request, old_diff_data=old_diff_data,
660 660 new_diff_data=new_diff_data)
661 661
662 662 file_changes = self._calculate_file_changes(
663 663 old_diff_data, new_diff_data)
664 664
665 665 # Add an automatic comment to the pull request
666 666 update_comment = CommentsModel().create(
667 667 text=self._render_update_message(changes, file_changes),
668 668 repo=pull_request.target_repo,
669 669 user=pull_request.author,
670 670 pull_request=pull_request,
671 671 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
672 672
673 673 # Update status to "Under Review" for added commits
674 674 for commit_id in changes.added:
675 675 ChangesetStatusModel().set_status(
676 676 repo=pull_request.source_repo,
677 677 status=ChangesetStatus.STATUS_UNDER_REVIEW,
678 678 comment=update_comment,
679 679 user=pull_request.author,
680 680 pull_request=pull_request,
681 681 revision=commit_id)
682 682
683 683 log.debug(
684 684 'Updated pull request %s, added_ids: %s, common_ids: %s, '
685 685 'removed_ids: %s', pull_request.pull_request_id,
686 686 changes.added, changes.common, changes.removed)
687 687 log.debug('Updated pull request with the following file changes: %s',
688 688 file_changes)
689 689
690 690 log.info(
691 691 "Updated pull request %s from commit %s to commit %s, "
692 692 "stored new version %s of this pull request.",
693 693 pull_request.pull_request_id, source_ref_id,
694 694 pull_request.source_ref_parts.commit_id,
695 695 pull_request_version.pull_request_version_id)
696 696 Session().commit()
697 697 self._trigger_pull_request_hook(pull_request, pull_request.author,
698 698 'update')
699 699
700 700 return UpdateResponse(
701 701 executed=True, reason=UpdateFailureReason.NONE,
702 702 old=pull_request, new=pull_request_version, changes=changes)
703 703
704 704 def _create_version_from_snapshot(self, pull_request):
705 705 version = PullRequestVersion()
706 706 version.title = pull_request.title
707 707 version.description = pull_request.description
708 708 version.status = pull_request.status
709 709 version.created_on = datetime.datetime.now()
710 710 version.updated_on = pull_request.updated_on
711 711 version.user_id = pull_request.user_id
712 712 version.source_repo = pull_request.source_repo
713 713 version.source_ref = pull_request.source_ref
714 714 version.target_repo = pull_request.target_repo
715 715 version.target_ref = pull_request.target_ref
716 716
717 717 version._last_merge_source_rev = pull_request._last_merge_source_rev
718 718 version._last_merge_target_rev = pull_request._last_merge_target_rev
719 719 version._last_merge_status = pull_request._last_merge_status
720 720 version.shadow_merge_ref = pull_request.shadow_merge_ref
721 721 version.merge_rev = pull_request.merge_rev
722 722
723 723 version.revisions = pull_request.revisions
724 724 version.pull_request = pull_request
725 725 Session().add(version)
726 726 Session().flush()
727 727
728 728 return version
729 729
730 730 def _generate_update_diffs(self, pull_request, pull_request_version):
731 731 diff_context = (
732 732 self.DIFF_CONTEXT +
733 733 CommentsModel.needed_extra_diff_context())
734 734 old_diff = self._get_diff_from_pr_or_version(
735 735 pull_request_version, context=diff_context)
736 736 new_diff = self._get_diff_from_pr_or_version(
737 737 pull_request, context=diff_context)
738 738
739 739 old_diff_data = diffs.DiffProcessor(old_diff)
740 740 old_diff_data.prepare()
741 741 new_diff_data = diffs.DiffProcessor(new_diff)
742 742 new_diff_data.prepare()
743 743
744 744 return old_diff_data, new_diff_data
745 745
746 746 def _link_comments_to_version(self, pull_request_version):
747 747 """
748 748 Link all unlinked comments of this pull request to the given version.
749 749
750 750 :param pull_request_version: The `PullRequestVersion` to which
751 751 the comments shall be linked.
752 752
753 753 """
754 754 pull_request = pull_request_version.pull_request
755 755 comments = ChangesetComment.query().filter(
756 756 # TODO: johbo: Should we query for the repo at all here?
757 757 # Pending decision on how comments of PRs are to be related
758 758 # to either the source repo, the target repo or no repo at all.
759 759 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
760 760 ChangesetComment.pull_request == pull_request,
761 761 ChangesetComment.pull_request_version == None)
762 762
763 763 # TODO: johbo: Find out why this breaks if it is done in a bulk
764 764 # operation.
765 765 for comment in comments:
766 766 comment.pull_request_version_id = (
767 767 pull_request_version.pull_request_version_id)
768 768 Session().add(comment)
769 769
770 770 def _calculate_commit_id_changes(self, old_ids, new_ids):
771 771 added = new_ids.difference(old_ids)
772 772 common = old_ids.intersection(new_ids)
773 773 removed = old_ids.difference(new_ids)
774 774 return ChangeTuple(added, common, removed)
775 775
776 776 def _calculate_file_changes(self, old_diff_data, new_diff_data):
777 777
778 778 old_files = OrderedDict()
779 779 for diff_data in old_diff_data.parsed_diff:
780 780 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
781 781
782 782 added_files = []
783 783 modified_files = []
784 784 removed_files = []
785 785 for diff_data in new_diff_data.parsed_diff:
786 786 new_filename = diff_data['filename']
787 787 new_hash = md5_safe(diff_data['raw_diff'])
788 788
789 789 old_hash = old_files.get(new_filename)
790 790 if not old_hash:
791 791 # file is not present in old diff, means it's added
792 792 added_files.append(new_filename)
793 793 else:
794 794 if new_hash != old_hash:
795 795 modified_files.append(new_filename)
796 796 # now remove a file from old, since we have seen it already
797 797 del old_files[new_filename]
798 798
799 799 # removed files is when there are present in old, but not in NEW,
800 800 # since we remove old files that are present in new diff, left-overs
801 801 # if any should be the removed files
802 802 removed_files.extend(old_files.keys())
803 803
804 804 return FileChangeTuple(added_files, modified_files, removed_files)
805 805
806 806 def _render_update_message(self, changes, file_changes):
807 807 """
808 808 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
809 809 so it's always looking the same disregarding on which default
810 810 renderer system is using.
811 811
812 812 :param changes: changes named tuple
813 813 :param file_changes: file changes named tuple
814 814
815 815 """
816 816 new_status = ChangesetStatus.get_status_lbl(
817 817 ChangesetStatus.STATUS_UNDER_REVIEW)
818 818
819 819 changed_files = (
820 820 file_changes.added + file_changes.modified + file_changes.removed)
821 821
822 822 params = {
823 823 'under_review_label': new_status,
824 824 'added_commits': changes.added,
825 825 'removed_commits': changes.removed,
826 826 'changed_files': changed_files,
827 827 'added_files': file_changes.added,
828 828 'modified_files': file_changes.modified,
829 829 'removed_files': file_changes.removed,
830 830 }
831 831 renderer = RstTemplateRenderer()
832 832 return renderer.render('pull_request_update.mako', **params)
833 833
834 834 def edit(self, pull_request, title, description):
835 835 pull_request = self.__get_pull_request(pull_request)
836 836 if pull_request.is_closed():
837 837 raise ValueError('This pull request is closed')
838 838 if title:
839 839 pull_request.title = title
840 840 pull_request.description = description
841 841 pull_request.updated_on = datetime.datetime.now()
842 842 Session().add(pull_request)
843 843
844 844 def update_reviewers(self, pull_request, reviewer_data):
845 845 """
846 846 Update the reviewers in the pull request
847 847
848 848 :param pull_request: the pr to update
849 849 :param reviewer_data: list of tuples [(user, ['reason1', 'reason2'])]
850 850 """
851 851
852 852 reviewers_reasons = {}
853 853 for user_id, reasons in reviewer_data:
854 854 if isinstance(user_id, (int, basestring)):
855 855 user_id = self._get_user(user_id).user_id
856 856 reviewers_reasons[user_id] = reasons
857 857
858 858 reviewers_ids = set(reviewers_reasons.keys())
859 859 pull_request = self.__get_pull_request(pull_request)
860 860 current_reviewers = PullRequestReviewers.query()\
861 861 .filter(PullRequestReviewers.pull_request ==
862 862 pull_request).all()
863 863 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
864 864
865 865 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
866 866 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
867 867
868 868 log.debug("Adding %s reviewers", ids_to_add)
869 869 log.debug("Removing %s reviewers", ids_to_remove)
870 870 changed = False
871 871 for uid in ids_to_add:
872 872 changed = True
873 873 _usr = self._get_user(uid)
874 874 reasons = reviewers_reasons[uid]
875 875 reviewer = PullRequestReviewers(_usr, pull_request, reasons)
876 876 Session().add(reviewer)
877 877
878 878 self.notify_reviewers(pull_request, ids_to_add)
879 879
880 880 for uid in ids_to_remove:
881 881 changed = True
882 882 reviewer = PullRequestReviewers.query()\
883 883 .filter(PullRequestReviewers.user_id == uid,
884 884 PullRequestReviewers.pull_request == pull_request)\
885 885 .scalar()
886 886 if reviewer:
887 887 Session().delete(reviewer)
888 888 if changed:
889 889 pull_request.updated_on = datetime.datetime.now()
890 890 Session().add(pull_request)
891 891
892 892 return ids_to_add, ids_to_remove
893 893
894 894 def get_url(self, pull_request):
895 895 return h.url('pullrequest_show',
896 896 repo_name=safe_str(pull_request.target_repo.repo_name),
897 897 pull_request_id=pull_request.pull_request_id,
898 898 qualified=True)
899 899
900 900 def get_shadow_clone_url(self, pull_request):
901 901 """
902 902 Returns qualified url pointing to the shadow repository. If this pull
903 903 request is closed there is no shadow repository and ``None`` will be
904 904 returned.
905 905 """
906 906 if pull_request.is_closed():
907 907 return None
908 908 else:
909 909 pr_url = urllib.unquote(self.get_url(pull_request))
910 910 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
911 911
912 912 def notify_reviewers(self, pull_request, reviewers_ids):
913 913 # notification to reviewers
914 914 if not reviewers_ids:
915 915 return
916 916
917 917 pull_request_obj = pull_request
918 918 # get the current participants of this pull request
919 919 recipients = reviewers_ids
920 920 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
921 921
922 922 pr_source_repo = pull_request_obj.source_repo
923 923 pr_target_repo = pull_request_obj.target_repo
924 924
925 925 pr_url = h.url(
926 926 'pullrequest_show',
927 927 repo_name=pr_target_repo.repo_name,
928 928 pull_request_id=pull_request_obj.pull_request_id,
929 929 qualified=True,)
930 930
931 931 # set some variables for email notification
932 932 pr_target_repo_url = h.url(
933 933 'summary_home',
934 934 repo_name=pr_target_repo.repo_name,
935 935 qualified=True)
936 936
937 937 pr_source_repo_url = h.url(
938 938 'summary_home',
939 939 repo_name=pr_source_repo.repo_name,
940 940 qualified=True)
941 941
942 942 # pull request specifics
943 943 pull_request_commits = [
944 944 (x.raw_id, x.message)
945 945 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
946 946
947 947 kwargs = {
948 948 'user': pull_request.author,
949 949 'pull_request': pull_request_obj,
950 950 'pull_request_commits': pull_request_commits,
951 951
952 952 'pull_request_target_repo': pr_target_repo,
953 953 'pull_request_target_repo_url': pr_target_repo_url,
954 954
955 955 'pull_request_source_repo': pr_source_repo,
956 956 'pull_request_source_repo_url': pr_source_repo_url,
957 957
958 958 'pull_request_url': pr_url,
959 959 }
960 960
961 961 # pre-generate the subject for notification itself
962 962 (subject,
963 963 _h, _e, # we don't care about those
964 964 body_plaintext) = EmailNotificationModel().render_email(
965 965 notification_type, **kwargs)
966 966
967 967 # create notification objects, and emails
968 968 NotificationModel().create(
969 969 created_by=pull_request.author,
970 970 notification_subject=subject,
971 971 notification_body=body_plaintext,
972 972 notification_type=notification_type,
973 973 recipients=recipients,
974 974 email_kwargs=kwargs,
975 975 )
976 976
977 977 def delete(self, pull_request):
978 978 pull_request = self.__get_pull_request(pull_request)
979 979 self._cleanup_merge_workspace(pull_request)
980 980 Session().delete(pull_request)
981 981
982 982 def close_pull_request(self, pull_request, user):
983 983 pull_request = self.__get_pull_request(pull_request)
984 984 self._cleanup_merge_workspace(pull_request)
985 985 pull_request.status = PullRequest.STATUS_CLOSED
986 986 pull_request.updated_on = datetime.datetime.now()
987 987 Session().add(pull_request)
988 988 self._trigger_pull_request_hook(
989 989 pull_request, pull_request.author, 'close')
990 990 self._log_action('user_closed_pull_request', user, pull_request)
991 991
992 992 def close_pull_request_with_comment(self, pull_request, user, repo,
993 993 message=None):
994 994 status = ChangesetStatus.STATUS_REJECTED
995 995
996 996 if not message:
997 997 message = (
998 998 _('Status change %(transition_icon)s %(status)s') % {
999 999 'transition_icon': '>',
1000 1000 'status': ChangesetStatus.get_status_lbl(status)})
1001 1001
1002 1002 internal_message = _('Closing with') + ' ' + message
1003 1003
1004 1004 comm = CommentsModel().create(
1005 1005 text=internal_message,
1006 1006 repo=repo.repo_id,
1007 1007 user=user.user_id,
1008 1008 pull_request=pull_request.pull_request_id,
1009 1009 f_path=None,
1010 1010 line_no=None,
1011 1011 status_change=ChangesetStatus.get_status_lbl(status),
1012 1012 status_change_type=status,
1013 1013 closing_pr=True
1014 1014 )
1015 1015
1016 1016 ChangesetStatusModel().set_status(
1017 1017 repo.repo_id,
1018 1018 status,
1019 1019 user.user_id,
1020 1020 comm,
1021 1021 pull_request=pull_request.pull_request_id
1022 1022 )
1023 1023 Session().flush()
1024 1024
1025 1025 PullRequestModel().close_pull_request(
1026 1026 pull_request.pull_request_id, user)
1027 1027
1028 1028 def merge_status(self, pull_request):
1029 1029 if not self._is_merge_enabled(pull_request):
1030 1030 return False, _('Server-side pull request merging is disabled.')
1031 1031 if pull_request.is_closed():
1032 1032 return False, _('This pull request is closed.')
1033 1033 merge_possible, msg = self._check_repo_requirements(
1034 1034 target=pull_request.target_repo, source=pull_request.source_repo)
1035 1035 if not merge_possible:
1036 1036 return merge_possible, msg
1037 1037
1038 1038 try:
1039 1039 resp = self._try_merge(pull_request)
1040 1040 log.debug("Merge response: %s", resp)
1041 1041 status = resp.possible, self.merge_status_message(
1042 1042 resp.failure_reason)
1043 1043 except NotImplementedError:
1044 1044 status = False, _('Pull request merging is not supported.')
1045 1045
1046 1046 return status
1047 1047
1048 1048 def _check_repo_requirements(self, target, source):
1049 1049 """
1050 1050 Check if `target` and `source` have compatible requirements.
1051 1051
1052 1052 Currently this is just checking for largefiles.
1053 1053 """
1054 1054 target_has_largefiles = self._has_largefiles(target)
1055 1055 source_has_largefiles = self._has_largefiles(source)
1056 1056 merge_possible = True
1057 1057 message = u''
1058 1058
1059 1059 if target_has_largefiles != source_has_largefiles:
1060 1060 merge_possible = False
1061 1061 if source_has_largefiles:
1062 1062 message = _(
1063 1063 'Target repository large files support is disabled.')
1064 1064 else:
1065 1065 message = _(
1066 1066 'Source repository large files support is disabled.')
1067 1067
1068 1068 return merge_possible, message
1069 1069
1070 1070 def _has_largefiles(self, repo):
1071 1071 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1072 1072 'extensions', 'largefiles')
1073 1073 return largefiles_ui and largefiles_ui[0].active
1074 1074
1075 1075 def _try_merge(self, pull_request):
1076 1076 """
1077 1077 Try to merge the pull request and return the merge status.
1078 1078 """
1079 1079 log.debug(
1080 1080 "Trying out if the pull request %s can be merged.",
1081 1081 pull_request.pull_request_id)
1082 1082 target_vcs = pull_request.target_repo.scm_instance()
1083 1083
1084 1084 # Refresh the target reference.
1085 1085 try:
1086 1086 target_ref = self._refresh_reference(
1087 1087 pull_request.target_ref_parts, target_vcs)
1088 1088 except CommitDoesNotExistError:
1089 1089 merge_state = MergeResponse(
1090 1090 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
1091 1091 return merge_state
1092 1092
1093 1093 target_locked = pull_request.target_repo.locked
1094 1094 if target_locked and target_locked[0]:
1095 1095 log.debug("The target repository is locked.")
1096 1096 merge_state = MergeResponse(
1097 1097 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
1098 1098 elif self._needs_merge_state_refresh(pull_request, target_ref):
1099 1099 log.debug("Refreshing the merge status of the repository.")
1100 1100 merge_state = self._refresh_merge_state(
1101 1101 pull_request, target_vcs, target_ref)
1102 1102 else:
1103 1103 possible = pull_request.\
1104 1104 _last_merge_status == MergeFailureReason.NONE
1105 1105 merge_state = MergeResponse(
1106 1106 possible, False, None, pull_request._last_merge_status)
1107 1107
1108 1108 return merge_state
1109 1109
1110 1110 def _refresh_reference(self, reference, vcs_repository):
1111 1111 if reference.type in ('branch', 'book'):
1112 1112 name_or_id = reference.name
1113 1113 else:
1114 1114 name_or_id = reference.commit_id
1115 1115 refreshed_commit = vcs_repository.get_commit(name_or_id)
1116 1116 refreshed_reference = Reference(
1117 1117 reference.type, reference.name, refreshed_commit.raw_id)
1118 1118 return refreshed_reference
1119 1119
1120 1120 def _needs_merge_state_refresh(self, pull_request, target_reference):
1121 1121 return not(
1122 1122 pull_request.revisions and
1123 1123 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1124 1124 target_reference.commit_id == pull_request._last_merge_target_rev)
1125 1125
1126 1126 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1127 1127 workspace_id = self._workspace_id(pull_request)
1128 1128 source_vcs = pull_request.source_repo.scm_instance()
1129 1129 use_rebase = self._use_rebase_for_merging(pull_request)
1130 1130 merge_state = target_vcs.merge(
1131 1131 target_reference, source_vcs, pull_request.source_ref_parts,
1132 1132 workspace_id, dry_run=True, use_rebase=use_rebase)
1133 1133
1134 1134 # Do not store the response if there was an unknown error.
1135 1135 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1136 1136 pull_request._last_merge_source_rev = \
1137 1137 pull_request.source_ref_parts.commit_id
1138 1138 pull_request._last_merge_target_rev = target_reference.commit_id
1139 1139 pull_request._last_merge_status = merge_state.failure_reason
1140 1140 pull_request.shadow_merge_ref = merge_state.merge_ref
1141 1141 Session().add(pull_request)
1142 1142 Session().commit()
1143 1143
1144 1144 return merge_state
1145 1145
1146 1146 def _workspace_id(self, pull_request):
1147 1147 workspace_id = 'pr-%s' % pull_request.pull_request_id
1148 1148 return workspace_id
1149 1149
1150 1150 def merge_status_message(self, status_code):
1151 1151 """
1152 1152 Return a human friendly error message for the given merge status code.
1153 1153 """
1154 1154 return self.MERGE_STATUS_MESSAGES[status_code]
1155 1155
1156 1156 def generate_repo_data(self, repo, commit_id=None, branch=None,
1157 1157 bookmark=None):
1158 1158 all_refs, selected_ref = \
1159 1159 self._get_repo_pullrequest_sources(
1160 1160 repo.scm_instance(), commit_id=commit_id,
1161 1161 branch=branch, bookmark=bookmark)
1162 1162
1163 1163 refs_select2 = []
1164 1164 for element in all_refs:
1165 1165 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1166 1166 refs_select2.append({'text': element[1], 'children': children})
1167 1167
1168 1168 return {
1169 1169 'user': {
1170 1170 'user_id': repo.user.user_id,
1171 1171 'username': repo.user.username,
1172 1172 'firstname': repo.user.firstname,
1173 1173 'lastname': repo.user.lastname,
1174 1174 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1175 1175 },
1176 1176 'description': h.chop_at_smart(repo.description, '\n'),
1177 1177 'refs': {
1178 1178 'all_refs': all_refs,
1179 1179 'selected_ref': selected_ref,
1180 1180 'select2_refs': refs_select2
1181 1181 }
1182 1182 }
1183 1183
1184 1184 def generate_pullrequest_title(self, source, source_ref, target):
1185 1185 return u'{source}#{at_ref} to {target}'.format(
1186 1186 source=source,
1187 1187 at_ref=source_ref,
1188 1188 target=target,
1189 1189 )
1190 1190
1191 1191 def _cleanup_merge_workspace(self, pull_request):
1192 1192 # Merging related cleanup
1193 1193 target_scm = pull_request.target_repo.scm_instance()
1194 1194 workspace_id = 'pr-%s' % pull_request.pull_request_id
1195 1195
1196 1196 try:
1197 1197 target_scm.cleanup_merge_workspace(workspace_id)
1198 1198 except NotImplementedError:
1199 1199 pass
1200 1200
1201 1201 def _get_repo_pullrequest_sources(
1202 1202 self, repo, commit_id=None, branch=None, bookmark=None):
1203 1203 """
1204 1204 Return a structure with repo's interesting commits, suitable for
1205 1205 the selectors in pullrequest controller
1206 1206
1207 1207 :param commit_id: a commit that must be in the list somehow
1208 1208 and selected by default
1209 1209 :param branch: a branch that must be in the list and selected
1210 1210 by default - even if closed
1211 1211 :param bookmark: a bookmark that must be in the list and selected
1212 1212 """
1213 1213
1214 1214 commit_id = safe_str(commit_id) if commit_id else None
1215 1215 branch = safe_str(branch) if branch else None
1216 1216 bookmark = safe_str(bookmark) if bookmark else None
1217 1217
1218 1218 selected = None
1219 1219
1220 1220 # order matters: first source that has commit_id in it will be selected
1221 1221 sources = []
1222 1222 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1223 1223 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1224 1224
1225 1225 if commit_id:
1226 1226 ref_commit = (h.short_id(commit_id), commit_id)
1227 1227 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1228 1228
1229 1229 sources.append(
1230 1230 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1231 1231 )
1232 1232
1233 1233 groups = []
1234 1234 for group_key, ref_list, group_name, match in sources:
1235 1235 group_refs = []
1236 1236 for ref_name, ref_id in ref_list:
1237 1237 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1238 1238 group_refs.append((ref_key, ref_name))
1239 1239
1240 1240 if not selected:
1241 1241 if set([commit_id, match]) & set([ref_id, ref_name]):
1242 1242 selected = ref_key
1243 1243
1244 1244 if group_refs:
1245 1245 groups.append((group_refs, group_name))
1246 1246
1247 1247 if not selected:
1248 1248 ref = commit_id or branch or bookmark
1249 1249 if ref:
1250 1250 raise CommitDoesNotExistError(
1251 1251 'No commit refs could be found matching: %s' % ref)
1252 1252 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1253 1253 selected = 'branch:%s:%s' % (
1254 1254 repo.DEFAULT_BRANCH_NAME,
1255 1255 repo.branches[repo.DEFAULT_BRANCH_NAME]
1256 1256 )
1257 1257 elif repo.commit_ids:
1258 1258 rev = repo.commit_ids[0]
1259 1259 selected = 'rev:%s:%s' % (rev, rev)
1260 1260 else:
1261 1261 raise EmptyRepositoryError()
1262 1262 return groups, selected
1263 1263
1264 1264 def get_diff(self, pull_request, context=DIFF_CONTEXT):
1265 1265 pull_request = self.__get_pull_request(pull_request)
1266 1266 return self._get_diff_from_pr_or_version(pull_request, context=context)
1267 1267
1268 1268 def _get_diff_from_pr_or_version(self, pr_or_version, context):
1269 1269 source_repo = pr_or_version.source_repo
1270 1270
1271 1271 # we swap org/other ref since we run a simple diff on one repo
1272 1272 target_ref_id = pr_or_version.target_ref_parts.commit_id
1273 1273 source_ref_id = pr_or_version.source_ref_parts.commit_id
1274 1274 target_commit = source_repo.get_commit(
1275 1275 commit_id=safe_str(target_ref_id))
1276 1276 source_commit = source_repo.get_commit(commit_id=safe_str(source_ref_id))
1277 1277 vcs_repo = source_repo.scm_instance()
1278 1278
1279 1279 # TODO: johbo: In the context of an update, we cannot reach
1280 1280 # the old commit anymore with our normal mechanisms. It needs
1281 1281 # some sort of special support in the vcs layer to avoid this
1282 1282 # workaround.
1283 1283 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1284 1284 vcs_repo.alias == 'git'):
1285 1285 source_commit.raw_id = safe_str(source_ref_id)
1286 1286
1287 1287 log.debug('calculating diff between '
1288 1288 'source_ref:%s and target_ref:%s for repo `%s`',
1289 1289 target_ref_id, source_ref_id,
1290 1290 safe_unicode(vcs_repo.path))
1291 1291
1292 1292 vcs_diff = vcs_repo.get_diff(
1293 1293 commit1=target_commit, commit2=source_commit, context=context)
1294 1294 return vcs_diff
1295 1295
1296 1296 def _is_merge_enabled(self, pull_request):
1297 1297 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1298 1298 settings = settings_model.get_general_settings()
1299 1299 return settings.get('rhodecode_pr_merge_enabled', False)
1300 1300
1301 1301 def _use_rebase_for_merging(self, pull_request):
1302 1302 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1303 1303 settings = settings_model.get_general_settings()
1304 1304 return settings.get('rhodecode_hg_use_rebase_for_merging', False)
1305 1305
1306 1306 def _log_action(self, action, user, pull_request):
1307 1307 action_logger(
1308 1308 user,
1309 1309 '{action}:{pr_id}'.format(
1310 1310 action=action, pr_id=pull_request.pull_request_id),
1311 1311 pull_request.target_repo)
1312 1312
1313 1313
1314 class MergeCheck(object):
1315 """
1316 Perform Merge Checks and returns a check object which stores information
1317 about merge errors, and merge conditions
1318 """
1319
1320 def __init__(self):
1321 self.merge_possible = None
1322 self.merge_msg = ''
1323 self.failed = None
1324 self.errors = []
1325
1326 def push_error(self, error_type, message):
1327 self.failed = True
1328 self.errors.append([error_type, message])
1329
1330 @classmethod
1331 def validate(cls, pull_request, user, fail_early=False, translator=None):
1332 # if migrated to pyramid...
1333 # _ = lambda: translator or _ # use passed in translator if any
1334
1335 merge_check = cls()
1336
1337 # permissions
1338 user_allowed_to_merge = PullRequestModel().check_user_merge(
1339 pull_request, user)
1340 if not user_allowed_to_merge:
1341 log.debug("MergeCheck: cannot merge, approval is pending.")
1342
1343 msg = _('User `{}` not allowed to perform merge').format(user)
1344 merge_check.push_error('error', msg)
1345 if fail_early:
1346 return merge_check
1347
1348 # review status
1349 review_status = pull_request.calculated_review_status()
1350 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1351 if not status_approved:
1352 log.debug("MergeCheck: cannot merge, approval is pending.")
1353
1354 msg = _('Pull request reviewer approval is pending.')
1355
1356 merge_check.push_error('warning', msg)
1357
1358 if fail_early:
1359 return merge_check
1360
1361 # left over TODOs
1362 todos = CommentsModel().get_unresolved_todos(pull_request)
1363 if todos:
1364 log.debug("MergeCheck: cannot merge, {} "
1365 "unresolved todos left.".format(len(todos)))
1366
1367 if len(todos) == 1:
1368 msg = _('Cannot merge, {} TODO still not resolved.').format(
1369 len(todos))
1370 else:
1371 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1372 len(todos))
1373
1374 merge_check.push_error('warning', msg)
1375
1376 if fail_early:
1377 return merge_check
1378
1379 # merge possible
1380 merge_status, msg = PullRequestModel().merge_status(pull_request)
1381 merge_check.merge_possible = merge_status
1382 merge_check.merge_msg = msg
1383 if not merge_status:
1384 log.debug(
1385 "MergeCheck: cannot merge, pull request merge not possible.")
1386 merge_check.push_error('warning', msg)
1387
1388 if fail_early:
1389 return merge_check
1390
1391 return merge_check
1392
1393
1314 1394 ChangeTuple = namedtuple('ChangeTuple',
1315 1395 ['added', 'common', 'removed'])
1316 1396
1317 1397 FileChangeTuple = namedtuple('FileChangeTuple',
1318 1398 ['added', 'modified', 'removed'])
@@ -1,2257 +1,2261 b''
1 1 //Primary CSS
2 2
3 3 //--- IMPORTS ------------------//
4 4
5 5 @import 'helpers';
6 6 @import 'mixins';
7 7 @import 'rcicons';
8 8 @import 'fonts';
9 9 @import 'variables';
10 10 @import 'bootstrap-variables';
11 11 @import 'form-bootstrap';
12 12 @import 'codemirror';
13 13 @import 'legacy_code_styles';
14 14 @import 'progress-bar';
15 15
16 16 @import 'type';
17 17 @import 'alerts';
18 18 @import 'buttons';
19 19 @import 'tags';
20 20 @import 'code-block';
21 21 @import 'examples';
22 22 @import 'login';
23 23 @import 'main-content';
24 24 @import 'select2';
25 25 @import 'comments';
26 26 @import 'panels-bootstrap';
27 27 @import 'panels';
28 28 @import 'deform';
29 29
30 30 //--- BASE ------------------//
31 31 .noscript-error {
32 32 top: 0;
33 33 left: 0;
34 34 width: 100%;
35 35 z-index: 101;
36 36 text-align: center;
37 37 font-family: @text-semibold;
38 38 font-size: 120%;
39 39 color: white;
40 40 background-color: @alert2;
41 41 padding: 5px 0 5px 0;
42 42 }
43 43
44 44 html {
45 45 display: table;
46 46 height: 100%;
47 47 width: 100%;
48 48 }
49 49
50 50 body {
51 51 display: table-cell;
52 52 width: 100%;
53 53 }
54 54
55 55 //--- LAYOUT ------------------//
56 56
57 57 .hidden{
58 58 display: none !important;
59 59 }
60 60
61 61 .box{
62 62 float: left;
63 63 width: 100%;
64 64 }
65 65
66 66 .browser-header {
67 67 clear: both;
68 68 }
69 69 .main {
70 70 clear: both;
71 71 padding:0 0 @pagepadding;
72 72 height: auto;
73 73
74 74 &:after { //clearfix
75 75 content:"";
76 76 clear:both;
77 77 width:100%;
78 78 display:block;
79 79 }
80 80 }
81 81
82 82 .action-link{
83 83 margin-left: @padding;
84 84 padding-left: @padding;
85 85 border-left: @border-thickness solid @border-default-color;
86 86 }
87 87
88 88 input + .action-link, .action-link.first{
89 89 border-left: none;
90 90 }
91 91
92 92 .action-link.last{
93 93 margin-right: @padding;
94 94 padding-right: @padding;
95 95 }
96 96
97 97 .action-link.active,
98 98 .action-link.active a{
99 99 color: @grey4;
100 100 }
101 101
102 102 ul.simple-list{
103 103 list-style: none;
104 104 margin: 0;
105 105 padding: 0;
106 106 }
107 107
108 108 .main-content {
109 109 padding-bottom: @pagepadding;
110 110 }
111 111
112 112 .wide-mode-wrapper {
113 113 max-width:4000px !important;
114 114 }
115 115
116 116 .wrapper {
117 117 position: relative;
118 118 max-width: @wrapper-maxwidth;
119 119 margin: 0 auto;
120 120 }
121 121
122 122 #content {
123 123 clear: both;
124 124 padding: 0 @contentpadding;
125 125 }
126 126
127 127 .advanced-settings-fields{
128 128 input{
129 129 margin-left: @textmargin;
130 130 margin-right: @padding/2;
131 131 }
132 132 }
133 133
134 134 .cs_files_title {
135 135 margin: @pagepadding 0 0;
136 136 }
137 137
138 138 input.inline[type="file"] {
139 139 display: inline;
140 140 }
141 141
142 142 .error_page {
143 143 margin: 10% auto;
144 144
145 145 h1 {
146 146 color: @grey2;
147 147 }
148 148
149 149 .alert {
150 150 margin: @padding 0;
151 151 }
152 152
153 153 .error-branding {
154 154 font-family: @text-semibold;
155 155 color: @grey4;
156 156 }
157 157
158 158 .error_message {
159 159 font-family: @text-regular;
160 160 }
161 161
162 162 .sidebar {
163 163 min-height: 275px;
164 164 margin: 0;
165 165 padding: 0 0 @sidebarpadding @sidebarpadding;
166 166 border: none;
167 167 }
168 168
169 169 .main-content {
170 170 position: relative;
171 171 margin: 0 @sidebarpadding @sidebarpadding;
172 172 padding: 0 0 0 @sidebarpadding;
173 173 border-left: @border-thickness solid @grey5;
174 174
175 175 @media (max-width:767px) {
176 176 clear: both;
177 177 width: 100%;
178 178 margin: 0;
179 179 border: none;
180 180 }
181 181 }
182 182
183 183 .inner-column {
184 184 float: left;
185 185 width: 29.75%;
186 186 min-height: 150px;
187 187 margin: @sidebarpadding 2% 0 0;
188 188 padding: 0 2% 0 0;
189 189 border-right: @border-thickness solid @grey5;
190 190
191 191 @media (max-width:767px) {
192 192 clear: both;
193 193 width: 100%;
194 194 border: none;
195 195 }
196 196
197 197 ul {
198 198 padding-left: 1.25em;
199 199 }
200 200
201 201 &:last-child {
202 202 margin: @sidebarpadding 0 0;
203 203 border: none;
204 204 }
205 205
206 206 h4 {
207 207 margin: 0 0 @padding;
208 208 font-family: @text-semibold;
209 209 }
210 210 }
211 211 }
212 212 .error-page-logo {
213 213 width: 130px;
214 214 height: 160px;
215 215 }
216 216
217 217 // HEADER
218 218 .header {
219 219
220 220 // TODO: johbo: Fix login pages, so that they work without a min-height
221 221 // for the header and then remove the min-height. I chose a smaller value
222 222 // intentionally here to avoid rendering issues in the main navigation.
223 223 min-height: 49px;
224 224
225 225 position: relative;
226 226 vertical-align: bottom;
227 227 padding: 0 @header-padding;
228 228 background-color: @grey2;
229 229 color: @grey5;
230 230
231 231 .title {
232 232 overflow: visible;
233 233 }
234 234
235 235 &:before,
236 236 &:after {
237 237 content: "";
238 238 clear: both;
239 239 width: 100%;
240 240 }
241 241
242 242 // TODO: johbo: Avoids breaking "Repositories" chooser
243 243 .select2-container .select2-choice .select2-arrow {
244 244 display: none;
245 245 }
246 246 }
247 247
248 248 #header-inner {
249 249 &.title {
250 250 margin: 0;
251 251 }
252 252 &:before,
253 253 &:after {
254 254 content: "";
255 255 clear: both;
256 256 }
257 257 }
258 258
259 259 // Gists
260 260 #files_data {
261 261 clear: both; //for firefox
262 262 }
263 263 #gistid {
264 264 margin-right: @padding;
265 265 }
266 266
267 267 // Global Settings Editor
268 268 .textarea.editor {
269 269 float: left;
270 270 position: relative;
271 271 max-width: @texteditor-width;
272 272
273 273 select {
274 274 position: absolute;
275 275 top:10px;
276 276 right:0;
277 277 }
278 278
279 279 .CodeMirror {
280 280 margin: 0;
281 281 }
282 282
283 283 .help-block {
284 284 margin: 0 0 @padding;
285 285 padding:.5em;
286 286 background-color: @grey6;
287 287 }
288 288 }
289 289
290 290 ul.auth_plugins {
291 291 margin: @padding 0 @padding @legend-width;
292 292 padding: 0;
293 293
294 294 li {
295 295 margin-bottom: @padding;
296 296 line-height: 1em;
297 297 list-style-type: none;
298 298
299 299 .auth_buttons .btn {
300 300 margin-right: @padding;
301 301 }
302 302
303 303 &:before { content: none; }
304 304 }
305 305 }
306 306
307 307
308 308 // My Account PR list
309 309
310 310 #show_closed {
311 311 margin: 0 1em 0 0;
312 312 }
313 313
314 314 .pullrequestlist {
315 315 .closed {
316 316 background-color: @grey6;
317 317 }
318 318 .td-status {
319 319 padding-left: .5em;
320 320 }
321 321 .log-container .truncate {
322 322 height: 2.75em;
323 323 white-space: pre-line;
324 324 }
325 325 table.rctable .user {
326 326 padding-left: 0;
327 327 }
328 328 table.rctable {
329 329 td.td-description,
330 330 .rc-user {
331 331 min-width: auto;
332 332 }
333 333 }
334 334 }
335 335
336 336 // Pull Requests
337 337
338 338 .pullrequests_section_head {
339 339 display: block;
340 340 clear: both;
341 341 margin: @padding 0;
342 342 font-family: @text-bold;
343 343 }
344 344
345 345 .pr-origininfo, .pr-targetinfo {
346 346 position: relative;
347 347
348 348 .tag {
349 349 display: inline-block;
350 350 margin: 0 1em .5em 0;
351 351 }
352 352
353 353 .clone-url {
354 354 display: inline-block;
355 355 margin: 0 0 .5em 0;
356 356 padding: 0;
357 357 line-height: 1.2em;
358 358 }
359 359 }
360 360
361 361 .pr-pullinfo {
362 362 clear: both;
363 363 margin: .5em 0;
364 364 }
365 365
366 366 #pr-title-input {
367 367 width: 72%;
368 368 font-size: 1em;
369 369 font-family: @text-bold;
370 370 margin: 0;
371 371 padding: 0 0 0 @padding/4;
372 372 line-height: 1.7em;
373 373 color: @text-color;
374 374 letter-spacing: .02em;
375 375 }
376 376
377 377 #pullrequest_title {
378 378 width: 100%;
379 379 box-sizing: border-box;
380 380 }
381 381
382 382 #pr_open_message {
383 383 border: @border-thickness solid #fff;
384 384 border-radius: @border-radius;
385 385 padding: @padding-large-vertical @padding-large-vertical @padding-large-vertical 0;
386 386 text-align: right;
387 387 overflow: hidden;
388 388 }
389 389
390 390 .pr-submit-button {
391 391 float: right;
392 392 margin: 0 0 0 5px;
393 393 }
394 394
395 395 .pr-spacing-container {
396 396 padding: 20px;
397 397 clear: both
398 398 }
399 399
400 400 #pr-description-input {
401 401 margin-bottom: 0;
402 402 }
403 403
404 404 .pr-description-label {
405 405 vertical-align: top;
406 406 }
407 407
408 408 .perms_section_head {
409 409 min-width: 625px;
410 410
411 411 h2 {
412 412 margin-bottom: 0;
413 413 }
414 414
415 415 .label-checkbox {
416 416 float: left;
417 417 }
418 418
419 419 &.field {
420 420 margin: @space 0 @padding;
421 421 }
422 422
423 423 &:first-child.field {
424 424 margin-top: 0;
425 425
426 426 .label {
427 427 margin-top: 0;
428 428 padding-top: 0;
429 429 }
430 430
431 431 .radios {
432 432 padding-top: 0;
433 433 }
434 434 }
435 435
436 436 .radios {
437 437 float: right;
438 438 position: relative;
439 439 width: 405px;
440 440 }
441 441 }
442 442
443 443 //--- MODULES ------------------//
444 444
445 445
446 446 // Server Announcement
447 447 #server-announcement {
448 448 width: 95%;
449 449 margin: @padding auto;
450 450 padding: @padding;
451 451 border-width: 2px;
452 452 border-style: solid;
453 453 .border-radius(2px);
454 454 font-family: @text-bold;
455 455
456 456 &.info { border-color: @alert4; background-color: @alert4-inner; }
457 457 &.warning { border-color: @alert3; background-color: @alert3-inner; }
458 458 &.error { border-color: @alert2; background-color: @alert2-inner; }
459 459 &.success { border-color: @alert1; background-color: @alert1-inner; }
460 460 &.neutral { border-color: @grey3; background-color: @grey6; }
461 461 }
462 462
463 463 // Fixed Sidebar Column
464 464 .sidebar-col-wrapper {
465 465 padding-left: @sidebar-all-width;
466 466
467 467 .sidebar {
468 468 width: @sidebar-width;
469 469 margin-left: -@sidebar-all-width;
470 470 }
471 471 }
472 472
473 473 .sidebar-col-wrapper.scw-small {
474 474 padding-left: @sidebar-small-all-width;
475 475
476 476 .sidebar {
477 477 width: @sidebar-small-width;
478 478 margin-left: -@sidebar-small-all-width;
479 479 }
480 480 }
481 481
482 482
483 483 // FOOTER
484 484 #footer {
485 485 padding: 0;
486 486 text-align: center;
487 487 vertical-align: middle;
488 488 color: @grey2;
489 489 background-color: @grey6;
490 490
491 491 p {
492 492 margin: 0;
493 493 padding: 1em;
494 494 line-height: 1em;
495 495 }
496 496
497 497 .server-instance { //server instance
498 498 display: none;
499 499 }
500 500
501 501 .title {
502 502 float: none;
503 503 margin: 0 auto;
504 504 }
505 505 }
506 506
507 507 button.close {
508 508 padding: 0;
509 509 cursor: pointer;
510 510 background: transparent;
511 511 border: 0;
512 512 .box-shadow(none);
513 513 -webkit-appearance: none;
514 514 }
515 515
516 516 .close {
517 517 float: right;
518 518 font-size: 21px;
519 519 font-family: @text-bootstrap;
520 520 line-height: 1em;
521 521 font-weight: bold;
522 522 color: @grey2;
523 523
524 524 &:hover,
525 525 &:focus {
526 526 color: @grey1;
527 527 text-decoration: none;
528 528 cursor: pointer;
529 529 }
530 530 }
531 531
532 532 // GRID
533 533 .sorting,
534 534 .sorting_desc,
535 535 .sorting_asc {
536 536 cursor: pointer;
537 537 }
538 538 .sorting_desc:after {
539 539 content: "\00A0\25B2";
540 540 font-size: .75em;
541 541 }
542 542 .sorting_asc:after {
543 543 content: "\00A0\25BC";
544 544 font-size: .68em;
545 545 }
546 546
547 547
548 548 .user_auth_tokens {
549 549
550 550 &.truncate {
551 551 white-space: nowrap;
552 552 overflow: hidden;
553 553 text-overflow: ellipsis;
554 554 }
555 555
556 556 .fields .field .input {
557 557 margin: 0;
558 558 }
559 559
560 560 input#description {
561 561 width: 100px;
562 562 margin: 0;
563 563 }
564 564
565 565 .drop-menu {
566 566 // TODO: johbo: Remove this, should work out of the box when
567 567 // having multiple inputs inline
568 568 margin: 0 0 0 5px;
569 569 }
570 570 }
571 571 #user_list_table {
572 572 .closed {
573 573 background-color: @grey6;
574 574 }
575 575 }
576 576
577 577
578 578 input {
579 579 &.disabled {
580 580 opacity: .5;
581 581 }
582 582 }
583 583
584 584 // remove extra padding in firefox
585 585 input::-moz-focus-inner { border:0; padding:0 }
586 586
587 587 .adjacent input {
588 588 margin-bottom: @padding;
589 589 }
590 590
591 591 .permissions_boxes {
592 592 display: block;
593 593 }
594 594
595 595 //TODO: lisa: this should be in tables
596 596 .show_more_col {
597 597 width: 20px;
598 598 }
599 599
600 600 //FORMS
601 601
602 602 .medium-inline,
603 603 input#description.medium-inline {
604 604 display: inline;
605 605 width: @medium-inline-input-width;
606 606 min-width: 100px;
607 607 }
608 608
609 609 select {
610 610 //reset
611 611 -webkit-appearance: none;
612 612 -moz-appearance: none;
613 613
614 614 display: inline-block;
615 615 height: 28px;
616 616 width: auto;
617 617 margin: 0 @padding @padding 0;
618 618 padding: 0 18px 0 8px;
619 619 line-height:1em;
620 620 font-size: @basefontsize;
621 621 border: @border-thickness solid @rcblue;
622 622 background:white url("../images/dt-arrow-dn.png") no-repeat 100% 50%;
623 623 color: @rcblue;
624 624
625 625 &:after {
626 626 content: "\00A0\25BE";
627 627 }
628 628
629 629 &:focus {
630 630 outline: none;
631 631 }
632 632 }
633 633
634 634 option {
635 635 &:focus {
636 636 outline: none;
637 637 }
638 638 }
639 639
640 640 input,
641 641 textarea {
642 642 padding: @input-padding;
643 643 border: @input-border-thickness solid @border-highlight-color;
644 644 .border-radius (@border-radius);
645 645 font-family: @text-light;
646 646 font-size: @basefontsize;
647 647
648 648 &.input-sm {
649 649 padding: 5px;
650 650 }
651 651
652 652 &#description {
653 653 min-width: @input-description-minwidth;
654 654 min-height: 1em;
655 655 padding: 10px;
656 656 }
657 657 }
658 658
659 659 .field-sm {
660 660 input,
661 661 textarea {
662 662 padding: 5px;
663 663 }
664 664 }
665 665
666 666 textarea {
667 667 display: block;
668 668 clear: both;
669 669 width: 100%;
670 670 min-height: 100px;
671 671 margin-bottom: @padding;
672 672 .box-sizing(border-box);
673 673 overflow: auto;
674 674 }
675 675
676 676 label {
677 677 font-family: @text-light;
678 678 }
679 679
680 680 // GRAVATARS
681 681 // centers gravatar on username to the right
682 682
683 683 .gravatar {
684 684 display: inline;
685 685 min-width: 16px;
686 686 min-height: 16px;
687 687 margin: -5px 0;
688 688 padding: 0;
689 689 line-height: 1em;
690 690 border: 1px solid @grey4;
691 691 box-sizing: content-box;
692 692
693 693 &.gravatar-large {
694 694 margin: -0.5em .25em -0.5em 0;
695 695 }
696 696
697 697 & + .user {
698 698 display: inline;
699 699 margin: 0;
700 700 padding: 0 0 0 .17em;
701 701 line-height: 1em;
702 702 }
703 703 }
704 704
705 705 .user-inline-data {
706 706 display: inline-block;
707 707 float: left;
708 708 padding-left: .5em;
709 709 line-height: 1.3em;
710 710 }
711 711
712 712 .rc-user { // gravatar + user wrapper
713 713 float: left;
714 714 position: relative;
715 715 min-width: 100px;
716 716 max-width: 200px;
717 717 min-height: (@gravatar-size + @border-thickness * 2); // account for border
718 718 display: block;
719 719 padding: 0 0 0 (@gravatar-size + @basefontsize/2 + @border-thickness * 2);
720 720
721 721
722 722 .gravatar {
723 723 display: block;
724 724 position: absolute;
725 725 top: 0;
726 726 left: 0;
727 727 min-width: @gravatar-size;
728 728 min-height: @gravatar-size;
729 729 margin: 0;
730 730 }
731 731
732 732 .user {
733 733 display: block;
734 734 max-width: 175px;
735 735 padding-top: 2px;
736 736 overflow: hidden;
737 737 text-overflow: ellipsis;
738 738 }
739 739 }
740 740
741 741 .gist-gravatar,
742 742 .journal_container {
743 743 .gravatar-large {
744 744 margin: 0 .5em -10px 0;
745 745 }
746 746 }
747 747
748 748
749 749 // ADMIN SETTINGS
750 750
751 751 // Tag Patterns
752 752 .tag_patterns {
753 753 .tag_input {
754 754 margin-bottom: @padding;
755 755 }
756 756 }
757 757
758 758 .locked_input {
759 759 position: relative;
760 760
761 761 input {
762 762 display: inline;
763 763 margin-top: 3px;
764 764 }
765 765
766 766 br {
767 767 display: none;
768 768 }
769 769
770 770 .error-message {
771 771 float: left;
772 772 width: 100%;
773 773 }
774 774
775 775 .lock_input_button {
776 776 display: inline;
777 777 }
778 778
779 779 .help-block {
780 780 clear: both;
781 781 }
782 782 }
783 783
784 784 // Notifications
785 785
786 786 .notifications_buttons {
787 787 margin: 0 0 @space 0;
788 788 padding: 0;
789 789
790 790 .btn {
791 791 display: inline-block;
792 792 }
793 793 }
794 794
795 795 .notification-list {
796 796
797 797 div {
798 798 display: inline-block;
799 799 vertical-align: middle;
800 800 }
801 801
802 802 .container {
803 803 display: block;
804 804 margin: 0 0 @padding 0;
805 805 }
806 806
807 807 .delete-notifications {
808 808 margin-left: @padding;
809 809 text-align: right;
810 810 cursor: pointer;
811 811 }
812 812
813 813 .read-notifications {
814 814 margin-left: @padding/2;
815 815 text-align: right;
816 816 width: 35px;
817 817 cursor: pointer;
818 818 }
819 819
820 820 .icon-minus-sign {
821 821 color: @alert2;
822 822 }
823 823
824 824 .icon-ok-sign {
825 825 color: @alert1;
826 826 }
827 827 }
828 828
829 829 .user_settings {
830 830 float: left;
831 831 clear: both;
832 832 display: block;
833 833 width: 100%;
834 834
835 835 .gravatar_box {
836 836 margin-bottom: @padding;
837 837
838 838 &:after {
839 839 content: " ";
840 840 clear: both;
841 841 width: 100%;
842 842 }
843 843 }
844 844
845 845 .fields .field {
846 846 clear: both;
847 847 }
848 848 }
849 849
850 850 .advanced_settings {
851 851 margin-bottom: @space;
852 852
853 853 .help-block {
854 854 margin-left: 0;
855 855 }
856 856
857 857 button + .help-block {
858 858 margin-top: @padding;
859 859 }
860 860 }
861 861
862 862 // admin settings radio buttons and labels
863 863 .label-2 {
864 864 float: left;
865 865 width: @label2-width;
866 866
867 867 label {
868 868 color: @grey1;
869 869 }
870 870 }
871 871 .checkboxes {
872 872 float: left;
873 873 width: @checkboxes-width;
874 874 margin-bottom: @padding;
875 875
876 876 .checkbox {
877 877 width: 100%;
878 878
879 879 label {
880 880 margin: 0;
881 881 padding: 0;
882 882 }
883 883 }
884 884
885 885 .checkbox + .checkbox {
886 886 display: inline-block;
887 887 }
888 888
889 889 label {
890 890 margin-right: 1em;
891 891 }
892 892 }
893 893
894 894 // CHANGELOG
895 895 .container_header {
896 896 float: left;
897 897 display: block;
898 898 width: 100%;
899 899 margin: @padding 0 @padding;
900 900
901 901 #filter_changelog {
902 902 float: left;
903 903 margin-right: @padding;
904 904 }
905 905
906 906 .breadcrumbs_light {
907 907 display: inline-block;
908 908 }
909 909 }
910 910
911 911 .info_box {
912 912 float: right;
913 913 }
914 914
915 915
916 916 #graph_nodes {
917 917 padding-top: 43px;
918 918 }
919 919
920 920 #graph_content{
921 921
922 922 // adjust for table headers so that graph renders properly
923 923 // #graph_nodes padding - table cell padding
924 924 padding-top: (@space - (@basefontsize * 2.4));
925 925
926 926 &.graph_full_width {
927 927 width: 100%;
928 928 max-width: 100%;
929 929 }
930 930 }
931 931
932 932 #graph {
933 933 .flag_status {
934 934 margin: 0;
935 935 }
936 936
937 937 .pagination-left {
938 938 float: left;
939 939 clear: both;
940 940 }
941 941
942 942 .log-container {
943 943 max-width: 345px;
944 944
945 945 .message{
946 946 max-width: 340px;
947 947 }
948 948 }
949 949
950 950 .graph-col-wrapper {
951 951 padding-left: 110px;
952 952
953 953 #graph_nodes {
954 954 width: 100px;
955 955 margin-left: -110px;
956 956 float: left;
957 957 clear: left;
958 958 }
959 959 }
960 960 }
961 961
962 962 #filter_changelog {
963 963 float: left;
964 964 }
965 965
966 966
967 967 //--- THEME ------------------//
968 968
969 969 #logo {
970 970 float: left;
971 971 margin: 9px 0 0 0;
972 972
973 973 .header {
974 974 background-color: transparent;
975 975 }
976 976
977 977 a {
978 978 display: inline-block;
979 979 }
980 980
981 981 img {
982 982 height:30px;
983 983 }
984 984 }
985 985
986 986 .logo-wrapper {
987 987 float:left;
988 988 }
989 989
990 990 .branding{
991 991 float: left;
992 992 padding: 9px 2px;
993 993 line-height: 1em;
994 994 font-size: @navigation-fontsize;
995 995 }
996 996
997 997 img {
998 998 border: none;
999 999 outline: none;
1000 1000 }
1001 1001 user-profile-header
1002 1002 label {
1003 1003
1004 1004 input[type="checkbox"] {
1005 1005 margin-right: 1em;
1006 1006 }
1007 1007 input[type="radio"] {
1008 1008 margin-right: 1em;
1009 1009 }
1010 1010 }
1011 1011
1012 1012 .flag_status {
1013 1013 margin: 2px 8px 6px 2px;
1014 1014 &.under_review {
1015 1015 .circle(5px, @alert3);
1016 1016 }
1017 1017 &.approved {
1018 1018 .circle(5px, @alert1);
1019 1019 }
1020 1020 &.rejected,
1021 1021 &.forced_closed{
1022 1022 .circle(5px, @alert2);
1023 1023 }
1024 1024 &.not_reviewed {
1025 1025 .circle(5px, @grey5);
1026 1026 }
1027 1027 }
1028 1028
1029 1029 .flag_status_comment_box {
1030 1030 margin: 5px 6px 0px 2px;
1031 1031 }
1032 1032 .test_pattern_preview {
1033 1033 margin: @space 0;
1034 1034
1035 1035 p {
1036 1036 margin-bottom: 0;
1037 1037 border-bottom: @border-thickness solid @border-default-color;
1038 1038 color: @grey3;
1039 1039 }
1040 1040
1041 1041 .btn {
1042 1042 margin-bottom: @padding;
1043 1043 }
1044 1044 }
1045 1045 #test_pattern_result {
1046 1046 display: none;
1047 1047 &:extend(pre);
1048 1048 padding: .9em;
1049 1049 color: @grey3;
1050 1050 background-color: @grey7;
1051 1051 border-right: @border-thickness solid @border-default-color;
1052 1052 border-bottom: @border-thickness solid @border-default-color;
1053 1053 border-left: @border-thickness solid @border-default-color;
1054 1054 }
1055 1055
1056 1056 #repo_vcs_settings {
1057 1057 #inherit_overlay_vcs_default {
1058 1058 display: none;
1059 1059 }
1060 1060 #inherit_overlay_vcs_custom {
1061 1061 display: custom;
1062 1062 }
1063 1063 &.inherited {
1064 1064 #inherit_overlay_vcs_default {
1065 1065 display: block;
1066 1066 }
1067 1067 #inherit_overlay_vcs_custom {
1068 1068 display: none;
1069 1069 }
1070 1070 }
1071 1071 }
1072 1072
1073 1073 .issue-tracker-link {
1074 1074 color: @rcblue;
1075 1075 }
1076 1076
1077 1077 // Issue Tracker Table Show/Hide
1078 1078 #repo_issue_tracker {
1079 1079 #inherit_overlay {
1080 1080 display: none;
1081 1081 }
1082 1082 #custom_overlay {
1083 1083 display: custom;
1084 1084 }
1085 1085 &.inherited {
1086 1086 #inherit_overlay {
1087 1087 display: block;
1088 1088 }
1089 1089 #custom_overlay {
1090 1090 display: none;
1091 1091 }
1092 1092 }
1093 1093 }
1094 1094 table.issuetracker {
1095 1095 &.readonly {
1096 1096 tr, td {
1097 1097 color: @grey3;
1098 1098 }
1099 1099 }
1100 1100 .edit {
1101 1101 display: none;
1102 1102 }
1103 1103 .editopen {
1104 1104 .edit {
1105 1105 display: inline;
1106 1106 }
1107 1107 .entry {
1108 1108 display: none;
1109 1109 }
1110 1110 }
1111 1111 tr td.td-action {
1112 1112 min-width: 117px;
1113 1113 }
1114 1114 td input {
1115 1115 max-width: none;
1116 1116 min-width: 30px;
1117 1117 width: 80%;
1118 1118 }
1119 1119 .issuetracker_pref input {
1120 1120 width: 40%;
1121 1121 }
1122 1122 input.edit_issuetracker_update {
1123 1123 margin-right: 0;
1124 1124 width: auto;
1125 1125 }
1126 1126 }
1127 1127
1128 1128 table.integrations {
1129 1129 .td-icon {
1130 1130 width: 20px;
1131 1131 .integration-icon {
1132 1132 height: 20px;
1133 1133 width: 20px;
1134 1134 }
1135 1135 }
1136 1136 }
1137 1137
1138 1138 .integrations {
1139 1139 a.integration-box {
1140 1140 color: @text-color;
1141 1141 &:hover {
1142 1142 .panel {
1143 1143 background: #fbfbfb;
1144 1144 }
1145 1145 }
1146 1146 .integration-icon {
1147 1147 width: 30px;
1148 1148 height: 30px;
1149 1149 margin-right: 20px;
1150 1150 float: left;
1151 1151 }
1152 1152
1153 1153 .panel-body {
1154 1154 padding: 10px;
1155 1155 }
1156 1156 .panel {
1157 1157 margin-bottom: 10px;
1158 1158 }
1159 1159 h2 {
1160 1160 display: inline-block;
1161 1161 margin: 0;
1162 1162 min-width: 140px;
1163 1163 }
1164 1164 }
1165 1165 }
1166 1166
1167 1167 //Permissions Settings
1168 1168 #add_perm {
1169 1169 margin: 0 0 @padding;
1170 1170 cursor: pointer;
1171 1171 }
1172 1172
1173 1173 .perm_ac {
1174 1174 input {
1175 1175 width: 95%;
1176 1176 }
1177 1177 }
1178 1178
1179 1179 .autocomplete-suggestions {
1180 1180 width: auto !important; // overrides autocomplete.js
1181 1181 margin: 0;
1182 1182 border: @border-thickness solid @rcblue;
1183 1183 border-radius: @border-radius;
1184 1184 color: @rcblue;
1185 1185 background-color: white;
1186 1186 }
1187 1187 .autocomplete-selected {
1188 1188 background: #F0F0F0;
1189 1189 }
1190 1190 .ac-container-wrap {
1191 1191 margin: 0;
1192 1192 padding: 8px;
1193 1193 border-bottom: @border-thickness solid @rclightblue;
1194 1194 list-style-type: none;
1195 1195 cursor: pointer;
1196 1196
1197 1197 &:hover {
1198 1198 background-color: @rclightblue;
1199 1199 }
1200 1200
1201 1201 img {
1202 1202 height: @gravatar-size;
1203 1203 width: @gravatar-size;
1204 1204 margin-right: 1em;
1205 1205 }
1206 1206
1207 1207 strong {
1208 1208 font-weight: normal;
1209 1209 }
1210 1210 }
1211 1211
1212 1212 // Settings Dropdown
1213 1213 .user-menu .container {
1214 1214 padding: 0 4px;
1215 1215 margin: 0;
1216 1216 }
1217 1217
1218 1218 .user-menu .gravatar {
1219 1219 cursor: pointer;
1220 1220 }
1221 1221
1222 1222 .codeblock {
1223 1223 margin-bottom: @padding;
1224 1224 clear: both;
1225 1225
1226 1226 .stats{
1227 1227 overflow: hidden;
1228 1228 }
1229 1229
1230 1230 .message{
1231 1231 textarea{
1232 1232 margin: 0;
1233 1233 }
1234 1234 }
1235 1235
1236 1236 .code-header {
1237 1237 .stats {
1238 1238 line-height: 2em;
1239 1239
1240 1240 .revision_id {
1241 1241 margin-left: 0;
1242 1242 }
1243 1243 .buttons {
1244 1244 padding-right: 0;
1245 1245 }
1246 1246 }
1247 1247
1248 1248 .item{
1249 1249 margin-right: 0.5em;
1250 1250 }
1251 1251 }
1252 1252
1253 1253 #editor_container{
1254 1254 position: relative;
1255 1255 margin: @padding;
1256 1256 }
1257 1257 }
1258 1258
1259 1259 #file_history_container {
1260 1260 display: none;
1261 1261 }
1262 1262
1263 1263 .file-history-inner {
1264 1264 margin-bottom: 10px;
1265 1265 }
1266 1266
1267 1267 // Pull Requests
1268 1268 .summary-details {
1269 1269 width: 72%;
1270 1270 }
1271 1271 .pr-summary {
1272 1272 border-bottom: @border-thickness solid @grey5;
1273 1273 margin-bottom: @space;
1274 1274 }
1275 1275 .reviewers-title {
1276 1276 width: 25%;
1277 1277 min-width: 200px;
1278 1278 }
1279 1279 .reviewers {
1280 1280 width: 25%;
1281 1281 min-width: 200px;
1282 1282 }
1283 1283 .reviewers ul li {
1284 1284 position: relative;
1285 1285 width: 100%;
1286 1286 margin-bottom: 8px;
1287 1287 }
1288 1288 .reviewers_member {
1289 1289 width: 100%;
1290 1290 overflow: auto;
1291 1291 }
1292 1292 .reviewer_reason {
1293 1293 padding-left: 20px;
1294 1294 }
1295 1295 .reviewer_status {
1296 1296 display: inline-block;
1297 1297 vertical-align: top;
1298 1298 width: 7%;
1299 1299 min-width: 20px;
1300 1300 height: 1.2em;
1301 1301 margin-top: 3px;
1302 1302 line-height: 1em;
1303 1303 }
1304 1304
1305 1305 .reviewer_name {
1306 1306 display: inline-block;
1307 1307 max-width: 83%;
1308 1308 padding-right: 20px;
1309 1309 vertical-align: middle;
1310 1310 line-height: 1;
1311 1311
1312 1312 .rc-user {
1313 1313 min-width: 0;
1314 1314 margin: -2px 1em 0 0;
1315 1315 }
1316 1316
1317 1317 .reviewer {
1318 1318 float: left;
1319 1319 }
1320 1320
1321 1321 &.to-delete {
1322 1322 .user,
1323 1323 .reviewer {
1324 1324 text-decoration: line-through;
1325 1325 }
1326 1326 }
1327 1327 }
1328 1328
1329 1329 .reviewer_member_remove {
1330 1330 position: absolute;
1331 1331 right: 0;
1332 1332 top: 0;
1333 1333 width: 16px;
1334 1334 margin-bottom: 10px;
1335 1335 padding: 0;
1336 1336 color: black;
1337 1337 }
1338 1338 .reviewer_member_status {
1339 1339 margin-top: 5px;
1340 1340 }
1341 1341 .pr-summary #summary{
1342 1342 width: 100%;
1343 1343 }
1344 1344 .pr-summary .action_button:hover {
1345 1345 border: 0;
1346 1346 cursor: pointer;
1347 1347 }
1348 1348 .pr-details-title {
1349 1349 padding-bottom: 8px;
1350 1350 border-bottom: @border-thickness solid @grey5;
1351 1351
1352 1352 .action_button.disabled {
1353 1353 color: @grey4;
1354 1354 cursor: inherit;
1355 1355 }
1356 1356 .action_button {
1357 1357 color: @rcblue;
1358 1358 }
1359 1359 }
1360 1360 .pr-details-content {
1361 1361 margin-top: @textmargin;
1362 1362 margin-bottom: @textmargin;
1363 1363 }
1364 1364 .pr-description {
1365 1365 white-space:pre-wrap;
1366 1366 }
1367 1367 .group_members {
1368 1368 margin-top: 0;
1369 1369 padding: 0;
1370 1370 list-style: outside none none;
1371 1371
1372 1372 img {
1373 1373 height: @gravatar-size;
1374 1374 width: @gravatar-size;
1375 1375 margin-right: .5em;
1376 1376 margin-left: 3px;
1377 1377 }
1378 1378
1379 1379 .to-delete {
1380 1380 .user {
1381 1381 text-decoration: line-through;
1382 1382 }
1383 1383 }
1384 1384 }
1385 1385
1386 1386 .compare_view_commits_title {
1387 1387 .disabled {
1388 1388 cursor: inherit;
1389 1389 &:hover{
1390 1390 background-color: inherit;
1391 1391 color: inherit;
1392 1392 }
1393 1393 }
1394 1394 }
1395 1395
1396 1396 // new entry in group_members
1397 1397 .td-author-new-entry {
1398 1398 background-color: rgba(red(@alert1), green(@alert1), blue(@alert1), 0.3);
1399 1399 }
1400 1400
1401 1401 .usergroup_member_remove {
1402 1402 width: 16px;
1403 1403 margin-bottom: 10px;
1404 1404 padding: 0;
1405 1405 color: black !important;
1406 1406 cursor: pointer;
1407 1407 }
1408 1408
1409 1409 .reviewer_ac .ac-input {
1410 1410 width: 92%;
1411 1411 margin-bottom: 1em;
1412 1412 }
1413 1413
1414 1414 .compare_view_commits tr{
1415 1415 height: 20px;
1416 1416 }
1417 1417 .compare_view_commits td {
1418 1418 vertical-align: top;
1419 1419 padding-top: 10px;
1420 1420 }
1421 1421 .compare_view_commits .author {
1422 1422 margin-left: 5px;
1423 1423 }
1424 1424
1425 1425 .compare_view_files {
1426 1426 width: 100%;
1427 1427
1428 1428 td {
1429 1429 vertical-align: middle;
1430 1430 }
1431 1431 }
1432 1432
1433 1433 .compare_view_filepath {
1434 1434 color: @grey1;
1435 1435 }
1436 1436
1437 1437 .show_more {
1438 1438 display: inline-block;
1439 1439 position: relative;
1440 1440 vertical-align: middle;
1441 1441 width: 4px;
1442 1442 height: @basefontsize;
1443 1443
1444 1444 &:after {
1445 1445 content: "\00A0\25BE";
1446 1446 display: inline-block;
1447 1447 width:10px;
1448 1448 line-height: 5px;
1449 1449 font-size: 12px;
1450 1450 cursor: pointer;
1451 1451 }
1452 1452 }
1453 1453
1454 1454 .journal_more .show_more {
1455 1455 display: inline;
1456 1456
1457 1457 &:after {
1458 1458 content: none;
1459 1459 }
1460 1460 }
1461 1461
1462 1462 .open .show_more:after,
1463 1463 .select2-dropdown-open .show_more:after {
1464 1464 .rotate(180deg);
1465 1465 margin-left: 4px;
1466 1466 }
1467 1467
1468 1468
1469 1469 .compare_view_commits .collapse_commit:after {
1470 1470 cursor: pointer;
1471 1471 content: "\00A0\25B4";
1472 1472 margin-left: -3px;
1473 1473 font-size: 17px;
1474 1474 color: @grey4;
1475 1475 }
1476 1476
1477 1477 .diff_links {
1478 1478 margin-left: 8px;
1479 1479 }
1480 1480
1481 1481 div.ancestor {
1482 1482 margin: -30px 0px;
1483 1483 }
1484 1484
1485 1485 .cs_icon_td input[type="checkbox"] {
1486 1486 display: none;
1487 1487 }
1488 1488
1489 1489 .cs_icon_td .expand_file_icon:after {
1490 1490 cursor: pointer;
1491 1491 content: "\00A0\25B6";
1492 1492 font-size: 12px;
1493 1493 color: @grey4;
1494 1494 }
1495 1495
1496 1496 .cs_icon_td .collapse_file_icon:after {
1497 1497 cursor: pointer;
1498 1498 content: "\00A0\25BC";
1499 1499 font-size: 12px;
1500 1500 color: @grey4;
1501 1501 }
1502 1502
1503 1503 /*new binary
1504 1504 NEW_FILENODE = 1
1505 1505 DEL_FILENODE = 2
1506 1506 MOD_FILENODE = 3
1507 1507 RENAMED_FILENODE = 4
1508 1508 COPIED_FILENODE = 5
1509 1509 CHMOD_FILENODE = 6
1510 1510 BIN_FILENODE = 7
1511 1511 */
1512 1512 .cs_files_expand {
1513 1513 font-size: @basefontsize + 5px;
1514 1514 line-height: 1.8em;
1515 1515 float: right;
1516 1516 }
1517 1517
1518 1518 .cs_files_expand span{
1519 1519 color: @rcblue;
1520 1520 cursor: pointer;
1521 1521 }
1522 1522 .cs_files {
1523 1523 clear: both;
1524 1524 padding-bottom: @padding;
1525 1525
1526 1526 .cur_cs {
1527 1527 margin: 10px 2px;
1528 1528 font-weight: bold;
1529 1529 }
1530 1530
1531 1531 .node {
1532 1532 float: left;
1533 1533 }
1534 1534
1535 1535 .changes {
1536 1536 float: right;
1537 1537 color: white;
1538 1538 font-size: @basefontsize - 4px;
1539 1539 margin-top: 4px;
1540 1540 opacity: 0.6;
1541 1541 filter: Alpha(opacity=60); /* IE8 and earlier */
1542 1542
1543 1543 .added {
1544 1544 background-color: @alert1;
1545 1545 float: left;
1546 1546 text-align: center;
1547 1547 }
1548 1548
1549 1549 .deleted {
1550 1550 background-color: @alert2;
1551 1551 float: left;
1552 1552 text-align: center;
1553 1553 }
1554 1554
1555 1555 .bin {
1556 1556 background-color: @alert1;
1557 1557 text-align: center;
1558 1558 }
1559 1559
1560 1560 /*new binary*/
1561 1561 .bin.bin1 {
1562 1562 background-color: @alert1;
1563 1563 text-align: center;
1564 1564 }
1565 1565
1566 1566 /*deleted binary*/
1567 1567 .bin.bin2 {
1568 1568 background-color: @alert2;
1569 1569 text-align: center;
1570 1570 }
1571 1571
1572 1572 /*mod binary*/
1573 1573 .bin.bin3 {
1574 1574 background-color: @grey2;
1575 1575 text-align: center;
1576 1576 }
1577 1577
1578 1578 /*rename file*/
1579 1579 .bin.bin4 {
1580 1580 background-color: @alert4;
1581 1581 text-align: center;
1582 1582 }
1583 1583
1584 1584 /*copied file*/
1585 1585 .bin.bin5 {
1586 1586 background-color: @alert4;
1587 1587 text-align: center;
1588 1588 }
1589 1589
1590 1590 /*chmod file*/
1591 1591 .bin.bin6 {
1592 1592 background-color: @grey2;
1593 1593 text-align: center;
1594 1594 }
1595 1595 }
1596 1596 }
1597 1597
1598 1598 .cs_files .cs_added, .cs_files .cs_A,
1599 1599 .cs_files .cs_added, .cs_files .cs_M,
1600 1600 .cs_files .cs_added, .cs_files .cs_D {
1601 1601 height: 16px;
1602 1602 padding-right: 10px;
1603 1603 margin-top: 7px;
1604 1604 text-align: left;
1605 1605 }
1606 1606
1607 1607 .cs_icon_td {
1608 1608 min-width: 16px;
1609 1609 width: 16px;
1610 1610 }
1611 1611
1612 1612 .pull-request-merge {
1613 1613 border: 1px solid @grey5;
1614 1614 padding: 10px 0px 20px;
1615 1615 margin-top: 10px;
1616 1616 margin-bottom: 20px;
1617 1617 }
1618 1618
1619 1619 .pull-request-merge ul {
1620 1620 padding: 0px 0px;
1621 1621 }
1622 1622
1623 1623 .pull-request-merge li:before{
1624 1624 content:none;
1625 1625 }
1626 1626
1627 1627 .pull-request-merge .pull-request-wrap {
1628 1628 height: auto;
1629 1629 padding: 0px 0px;
1630 1630 text-align: right;
1631 1631 }
1632 1632
1633 1633 .pull-request-merge span {
1634 1634 margin-right: 5px;
1635 1635 }
1636 1636
1637 1637 .pull-request-merge-actions {
1638 1638 height: 30px;
1639 1639 padding: 0px 0px;
1640 1640 }
1641 1641
1642 .merge-status {
1643 margin-right: 5px;
1644 }
1645
1642 1646 .merge-message {
1643 1647 font-size: 1.2em
1644 1648 }
1645 .merge-message li{
1646 text-decoration: none;
1647 }
1648 1649
1649 .merge-message.success i {
1650 .merge-message.success i,
1651 .merge-icon.success i {
1650 1652 color:@alert1;
1651 1653 }
1652 .merge-message.warning i {
1654
1655 .merge-message.warning i,
1656 .merge-icon.warning i {
1653 1657 color: @alert3;
1654 1658 }
1655 .merge-message.error i {
1659
1660 .merge-message.error i,
1661 .merge-icon.error i {
1656 1662 color:@alert2;
1657 1663 }
1658 1664
1659
1660
1661 1665 .pr-versions {
1662 1666 position: relative;
1663 1667 top: 6px;
1664 1668 }
1665 1669
1666 1670 #close_pull_request {
1667 1671 margin-right: 0px;
1668 1672 }
1669 1673
1670 1674 .empty_data {
1671 1675 color: @grey4;
1672 1676 }
1673 1677
1674 1678 #changeset_compare_view_content {
1675 1679 margin-bottom: @space;
1676 1680 clear: both;
1677 1681 width: 100%;
1678 1682 box-sizing: border-box;
1679 1683 .border-radius(@border-radius);
1680 1684
1681 1685 .help-block {
1682 1686 margin: @padding 0;
1683 1687 color: @text-color;
1684 1688 }
1685 1689
1686 1690 .empty_data {
1687 1691 margin: @padding 0;
1688 1692 }
1689 1693
1690 1694 .alert {
1691 1695 margin-bottom: @space;
1692 1696 }
1693 1697 }
1694 1698
1695 1699 .table_disp {
1696 1700 .status {
1697 1701 width: auto;
1698 1702
1699 1703 .flag_status {
1700 1704 float: left;
1701 1705 }
1702 1706 }
1703 1707 }
1704 1708
1705 1709 .status_box_menu {
1706 1710 margin: 0;
1707 1711 }
1708 1712
1709 1713 .notification-table{
1710 1714 margin-bottom: @space;
1711 1715 display: table;
1712 1716 width: 100%;
1713 1717
1714 1718 .container{
1715 1719 display: table-row;
1716 1720
1717 1721 .notification-header{
1718 1722 border-bottom: @border-thickness solid @border-default-color;
1719 1723 }
1720 1724
1721 1725 .notification-subject{
1722 1726 display: table-cell;
1723 1727 }
1724 1728 }
1725 1729 }
1726 1730
1727 1731 // Notifications
1728 1732 .notification-header{
1729 1733 display: table;
1730 1734 width: 100%;
1731 1735 padding: floor(@basefontsize/2) 0;
1732 1736 line-height: 1em;
1733 1737
1734 1738 .desc, .delete-notifications, .read-notifications{
1735 1739 display: table-cell;
1736 1740 text-align: left;
1737 1741 }
1738 1742
1739 1743 .desc{
1740 1744 width: 1163px;
1741 1745 }
1742 1746
1743 1747 .delete-notifications, .read-notifications{
1744 1748 width: 35px;
1745 1749 min-width: 35px; //fixes when only one button is displayed
1746 1750 }
1747 1751 }
1748 1752
1749 1753 .notification-body {
1750 1754 .markdown-block,
1751 1755 .rst-block {
1752 1756 padding: @padding 0;
1753 1757 }
1754 1758
1755 1759 .notification-subject {
1756 1760 padding: @textmargin 0;
1757 1761 border-bottom: @border-thickness solid @border-default-color;
1758 1762 }
1759 1763 }
1760 1764
1761 1765
1762 1766 .notifications_buttons{
1763 1767 float: right;
1764 1768 }
1765 1769
1766 1770 #notification-status{
1767 1771 display: inline;
1768 1772 }
1769 1773
1770 1774 // Repositories
1771 1775
1772 1776 #summary.fields{
1773 1777 display: table;
1774 1778
1775 1779 .field{
1776 1780 display: table-row;
1777 1781
1778 1782 .label-summary{
1779 1783 display: table-cell;
1780 1784 min-width: @label-summary-minwidth;
1781 1785 padding-top: @padding/2;
1782 1786 padding-bottom: @padding/2;
1783 1787 padding-right: @padding/2;
1784 1788 }
1785 1789
1786 1790 .input{
1787 1791 display: table-cell;
1788 1792 padding: @padding/2;
1789 1793
1790 1794 input{
1791 1795 min-width: 29em;
1792 1796 padding: @padding/4;
1793 1797 }
1794 1798 }
1795 1799 .statistics, .downloads{
1796 1800 .disabled{
1797 1801 color: @grey4;
1798 1802 }
1799 1803 }
1800 1804 }
1801 1805 }
1802 1806
1803 1807 #summary{
1804 1808 width: 70%;
1805 1809 }
1806 1810
1807 1811
1808 1812 // Journal
1809 1813 .journal.title {
1810 1814 h5 {
1811 1815 float: left;
1812 1816 margin: 0;
1813 1817 width: 70%;
1814 1818 }
1815 1819
1816 1820 ul {
1817 1821 float: right;
1818 1822 display: inline-block;
1819 1823 margin: 0;
1820 1824 width: 30%;
1821 1825 text-align: right;
1822 1826
1823 1827 li {
1824 1828 display: inline;
1825 1829 font-size: @journal-fontsize;
1826 1830 line-height: 1em;
1827 1831
1828 1832 &:before { content: none; }
1829 1833 }
1830 1834 }
1831 1835 }
1832 1836
1833 1837 .filterexample {
1834 1838 position: absolute;
1835 1839 top: 95px;
1836 1840 left: @contentpadding;
1837 1841 color: @rcblue;
1838 1842 font-size: 11px;
1839 1843 font-family: @text-regular;
1840 1844 cursor: help;
1841 1845
1842 1846 &:hover {
1843 1847 color: @rcdarkblue;
1844 1848 }
1845 1849
1846 1850 @media (max-width:768px) {
1847 1851 position: relative;
1848 1852 top: auto;
1849 1853 left: auto;
1850 1854 display: block;
1851 1855 }
1852 1856 }
1853 1857
1854 1858
1855 1859 #journal{
1856 1860 margin-bottom: @space;
1857 1861
1858 1862 .journal_day{
1859 1863 margin-bottom: @textmargin/2;
1860 1864 padding-bottom: @textmargin/2;
1861 1865 font-size: @journal-fontsize;
1862 1866 border-bottom: @border-thickness solid @border-default-color;
1863 1867 }
1864 1868
1865 1869 .journal_container{
1866 1870 margin-bottom: @space;
1867 1871
1868 1872 .journal_user{
1869 1873 display: inline-block;
1870 1874 }
1871 1875 .journal_action_container{
1872 1876 display: block;
1873 1877 margin-top: @textmargin;
1874 1878
1875 1879 div{
1876 1880 display: inline;
1877 1881 }
1878 1882
1879 1883 div.journal_action_params{
1880 1884 display: block;
1881 1885 }
1882 1886
1883 1887 div.journal_repo:after{
1884 1888 content: "\A";
1885 1889 white-space: pre;
1886 1890 }
1887 1891
1888 1892 div.date{
1889 1893 display: block;
1890 1894 margin-bottom: @textmargin;
1891 1895 }
1892 1896 }
1893 1897 }
1894 1898 }
1895 1899
1896 1900 // Files
1897 1901 .edit-file-title {
1898 1902 border-bottom: @border-thickness solid @border-default-color;
1899 1903
1900 1904 .breadcrumbs {
1901 1905 margin-bottom: 0;
1902 1906 }
1903 1907 }
1904 1908
1905 1909 .edit-file-fieldset {
1906 1910 margin-top: @sidebarpadding;
1907 1911
1908 1912 .fieldset {
1909 1913 .left-label {
1910 1914 width: 13%;
1911 1915 }
1912 1916 .right-content {
1913 1917 width: 87%;
1914 1918 max-width: 100%;
1915 1919 }
1916 1920 .filename-label {
1917 1921 margin-top: 13px;
1918 1922 }
1919 1923 .commit-message-label {
1920 1924 margin-top: 4px;
1921 1925 }
1922 1926 .file-upload-input {
1923 1927 input {
1924 1928 display: none;
1925 1929 }
1926 1930 }
1927 1931 p {
1928 1932 margin-top: 5px;
1929 1933 }
1930 1934
1931 1935 }
1932 1936 .custom-path-link {
1933 1937 margin-left: 5px;
1934 1938 }
1935 1939 #commit {
1936 1940 resize: vertical;
1937 1941 }
1938 1942 }
1939 1943
1940 1944 .delete-file-preview {
1941 1945 max-height: 250px;
1942 1946 }
1943 1947
1944 1948 .new-file,
1945 1949 #filter_activate,
1946 1950 #filter_deactivate {
1947 1951 float: left;
1948 1952 margin: 0 0 0 15px;
1949 1953 }
1950 1954
1951 1955 h3.files_location{
1952 1956 line-height: 2.4em;
1953 1957 }
1954 1958
1955 1959 .browser-nav {
1956 1960 display: table;
1957 1961 margin-bottom: @space;
1958 1962
1959 1963
1960 1964 .info_box {
1961 1965 display: inline-table;
1962 1966 height: 2.5em;
1963 1967
1964 1968 .browser-cur-rev, .info_box_elem {
1965 1969 display: table-cell;
1966 1970 vertical-align: middle;
1967 1971 }
1968 1972
1969 1973 .info_box_elem {
1970 1974 border-top: @border-thickness solid @rcblue;
1971 1975 border-bottom: @border-thickness solid @rcblue;
1972 1976
1973 1977 #at_rev, a {
1974 1978 padding: 0.6em 0.9em;
1975 1979 margin: 0;
1976 1980 .box-shadow(none);
1977 1981 border: 0;
1978 1982 height: 12px;
1979 1983 }
1980 1984
1981 1985 input#at_rev {
1982 1986 max-width: 50px;
1983 1987 text-align: right;
1984 1988 }
1985 1989
1986 1990 &.previous {
1987 1991 border: @border-thickness solid @rcblue;
1988 1992 .disabled {
1989 1993 color: @grey4;
1990 1994 cursor: not-allowed;
1991 1995 }
1992 1996 }
1993 1997
1994 1998 &.next {
1995 1999 border: @border-thickness solid @rcblue;
1996 2000 .disabled {
1997 2001 color: @grey4;
1998 2002 cursor: not-allowed;
1999 2003 }
2000 2004 }
2001 2005 }
2002 2006
2003 2007 .browser-cur-rev {
2004 2008
2005 2009 span{
2006 2010 margin: 0;
2007 2011 color: @rcblue;
2008 2012 height: 12px;
2009 2013 display: inline-block;
2010 2014 padding: 0.7em 1em ;
2011 2015 border: @border-thickness solid @rcblue;
2012 2016 margin-right: @padding;
2013 2017 }
2014 2018 }
2015 2019 }
2016 2020
2017 2021 .search_activate {
2018 2022 display: table-cell;
2019 2023 vertical-align: middle;
2020 2024
2021 2025 input, label{
2022 2026 margin: 0;
2023 2027 padding: 0;
2024 2028 }
2025 2029
2026 2030 input{
2027 2031 margin-left: @textmargin;
2028 2032 }
2029 2033
2030 2034 }
2031 2035 }
2032 2036
2033 2037 .browser-cur-rev{
2034 2038 margin-bottom: @textmargin;
2035 2039 }
2036 2040
2037 2041 #node_filter_box_loading{
2038 2042 .info_text;
2039 2043 }
2040 2044
2041 2045 .browser-search {
2042 2046 margin: -25px 0px 5px 0px;
2043 2047 }
2044 2048
2045 2049 .node-filter {
2046 2050 font-size: @repo-title-fontsize;
2047 2051 padding: 4px 0px 0px 0px;
2048 2052
2049 2053 .node-filter-path {
2050 2054 float: left;
2051 2055 color: @grey4;
2052 2056 }
2053 2057 .node-filter-input {
2054 2058 float: left;
2055 2059 margin: -2px 0px 0px 2px;
2056 2060 input {
2057 2061 padding: 2px;
2058 2062 border: none;
2059 2063 font-size: @repo-title-fontsize;
2060 2064 }
2061 2065 }
2062 2066 }
2063 2067
2064 2068
2065 2069 .browser-result{
2066 2070 td a{
2067 2071 margin-left: 0.5em;
2068 2072 display: inline-block;
2069 2073
2070 2074 em{
2071 2075 font-family: @text-bold;
2072 2076 }
2073 2077 }
2074 2078 }
2075 2079
2076 2080 .browser-highlight{
2077 2081 background-color: @grey5-alpha;
2078 2082 }
2079 2083
2080 2084
2081 2085 // Search
2082 2086
2083 2087 .search-form{
2084 2088 #q {
2085 2089 width: @search-form-width;
2086 2090 }
2087 2091 .fields{
2088 2092 margin: 0 0 @space;
2089 2093 }
2090 2094
2091 2095 label{
2092 2096 display: inline-block;
2093 2097 margin-right: @textmargin;
2094 2098 padding-top: 0.25em;
2095 2099 }
2096 2100
2097 2101
2098 2102 .results{
2099 2103 clear: both;
2100 2104 margin: 0 0 @padding;
2101 2105 }
2102 2106 }
2103 2107
2104 2108 div.search-feedback-items {
2105 2109 display: inline-block;
2106 2110 padding:0px 0px 0px 96px;
2107 2111 }
2108 2112
2109 2113 div.search-code-body {
2110 2114 background-color: #ffffff; padding: 5px 0 5px 10px;
2111 2115 pre {
2112 2116 .match { background-color: #faffa6;}
2113 2117 .break { display: block; width: 100%; background-color: #DDE7EF; color: #747474; }
2114 2118 }
2115 2119 }
2116 2120
2117 2121 .expand_commit.search {
2118 2122 .show_more.open {
2119 2123 height: auto;
2120 2124 max-height: none;
2121 2125 }
2122 2126 }
2123 2127
2124 2128 .search-results {
2125 2129
2126 2130 h2 {
2127 2131 margin-bottom: 0;
2128 2132 }
2129 2133 .codeblock {
2130 2134 border: none;
2131 2135 background: transparent;
2132 2136 }
2133 2137
2134 2138 .codeblock-header {
2135 2139 border: none;
2136 2140 background: transparent;
2137 2141 }
2138 2142
2139 2143 .code-body {
2140 2144 border: @border-thickness solid @border-default-color;
2141 2145 .border-radius(@border-radius);
2142 2146 }
2143 2147
2144 2148 .td-commit {
2145 2149 &:extend(pre);
2146 2150 border-bottom: @border-thickness solid @border-default-color;
2147 2151 }
2148 2152
2149 2153 .message {
2150 2154 height: auto;
2151 2155 max-width: 350px;
2152 2156 white-space: normal;
2153 2157 text-overflow: initial;
2154 2158 overflow: visible;
2155 2159
2156 2160 .match { background-color: #faffa6;}
2157 2161 .break { background-color: #DDE7EF; width: 100%; color: #747474; display: block; }
2158 2162 }
2159 2163
2160 2164 }
2161 2165
2162 2166 table.rctable td.td-search-results div {
2163 2167 max-width: 100%;
2164 2168 }
2165 2169
2166 2170 #tip-box, .tip-box{
2167 2171 padding: @menupadding/2;
2168 2172 display: block;
2169 2173 border: @border-thickness solid @border-highlight-color;
2170 2174 .border-radius(@border-radius);
2171 2175 background-color: white;
2172 2176 z-index: 99;
2173 2177 white-space: pre-wrap;
2174 2178 }
2175 2179
2176 2180 #linktt {
2177 2181 width: 79px;
2178 2182 }
2179 2183
2180 2184 #help_kb .modal-content{
2181 2185 max-width: 750px;
2182 2186 margin: 10% auto;
2183 2187
2184 2188 table{
2185 2189 td,th{
2186 2190 border-bottom: none;
2187 2191 line-height: 2.5em;
2188 2192 }
2189 2193 th{
2190 2194 padding-bottom: @textmargin/2;
2191 2195 }
2192 2196 td.keys{
2193 2197 text-align: center;
2194 2198 }
2195 2199 }
2196 2200
2197 2201 .block-left{
2198 2202 width: 45%;
2199 2203 margin-right: 5%;
2200 2204 }
2201 2205 .modal-footer{
2202 2206 clear: both;
2203 2207 }
2204 2208 .key.tag{
2205 2209 padding: 0.5em;
2206 2210 background-color: @rcblue;
2207 2211 color: white;
2208 2212 border-color: @rcblue;
2209 2213 .box-shadow(none);
2210 2214 }
2211 2215 }
2212 2216
2213 2217
2214 2218
2215 2219 //--- IMPORTS FOR REFACTORED STYLES ------------------//
2216 2220
2217 2221 @import 'statistics-graph';
2218 2222 @import 'tables';
2219 2223 @import 'forms';
2220 2224 @import 'diff';
2221 2225 @import 'summary';
2222 2226 @import 'navigation';
2223 2227
2224 2228 //--- SHOW/HIDE SECTIONS --//
2225 2229
2226 2230 .btn-collapse {
2227 2231 float: right;
2228 2232 text-align: right;
2229 2233 font-family: @text-light;
2230 2234 font-size: @basefontsize;
2231 2235 cursor: pointer;
2232 2236 border: none;
2233 2237 color: @rcblue;
2234 2238 }
2235 2239
2236 2240 table.rctable,
2237 2241 table.dataTable {
2238 2242 .btn-collapse {
2239 2243 float: right;
2240 2244 text-align: right;
2241 2245 }
2242 2246 }
2243 2247
2244 2248
2245 2249 // TODO: johbo: Fix for IE10, this avoids that we see a border
2246 2250 // and padding around checkboxes and radio boxes. Move to the right place,
2247 2251 // or better: Remove this once we did the form refactoring.
2248 2252 input[type=checkbox],
2249 2253 input[type=radio] {
2250 2254 padding: 0;
2251 2255 border: none;
2252 2256 }
2253 2257
2254 2258 .toggle-ajax-spinner{
2255 2259 height: 16px;
2256 2260 width: 16px;
2257 2261 }
@@ -1,37 +1,44 b''
1 1
2 2 <div class="pull-request-wrap">
3 3
4
5 % if c.pr_merge_possible:
6 <h2 class="merge-status">
7 <span class="merge-icon success"><i class="icon-true"></i></span>
8 ${_('This pull request can be merged automatically.')}
9 </h2>
10 % else:
11 <h2 class="merge-status">
12 <span class="merge-icon warning"><i class="icon-false"></i></span>
13 ${_('Merge is not currently possible because of below failed checks.')}
14 </h2>
15 % endif
16
4 17 <ul>
5 % for pr_check_type, pr_check_msg in c.pr_merge_checks:
18 % for pr_check_type, pr_check_msg in c.pr_merge_errors:
6 19 <li>
7 20 <span class="merge-message ${pr_check_type}" data-role="merge-message">
8 % if pr_check_type in ['success']:
9 <i class="icon-true"></i>
10 % else:
11 <i class="icon-false"></i>
12 % endif
13 ${pr_check_msg}
21 - ${pr_check_msg}
14 22 </span>
15 23 </li>
16 24 % endfor
17 25 </ul>
18 26
19 27 <div class="pull-request-merge-actions">
20 28 % if c.allowed_to_merge:
21 29 <div class="pull-right">
22 30 ${h.secure_form(url('pullrequest_merge', repo_name=c.repo_name, pull_request_id=c.pull_request.pull_request_id), id='merge_pull_request_form')}
23 <% merge_disabled = ' disabled' if c.pr_merge_status is False else '' %>
31 <% merge_disabled = ' disabled' if c.pr_merge_possible is False else '' %>
24 32 <a class="btn" href="#" onclick="refreshMergeChecks(); return false;">${_('refresh checks')}</a>
25 33 <input type="submit" id="merge_pull_request" value="${_('Merge Pull Request')}" class="btn${merge_disabled}"${merge_disabled}>
26 34 ${h.end_form()}
27 35 </div>
28 36 % elif c.rhodecode_user.username != h.DEFAULT_USER:
29 37 <a class="btn" href="#" onclick="refreshMergeChecks(); return false;">${_('refresh checks')}</a>
30 38 <input type="submit" value="${_('Merge Pull Request')}" class="btn disabled" disabled="disabled" title="${_('You are not allowed to merge this pull request.')}">
31 39 % else:
32 40 <input type="submit" value="${_('Login to Merge this Pull Request')}" class="btn disabled" disabled="disabled">
33 41 % endif
34 42 </div>
35
36 43 </div>
37 44
@@ -1,1064 +1,1067 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import mock
22 22 import pytest
23 23 from webob.exc import HTTPNotFound
24 24
25 25 import rhodecode
26 26 from rhodecode.lib.vcs.nodes import FileNode
27 27 from rhodecode.model.changeset_status import ChangesetStatusModel
28 28 from rhodecode.model.db import (
29 29 PullRequest, ChangesetStatus, UserLog, Notification)
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.model.repo import RepoModel
34 34 from rhodecode.tests import assert_session_flash, url, TEST_USER_ADMIN_LOGIN
35 35 from rhodecode.tests.utils import AssertResponse
36 36
37 37
38 38 @pytest.mark.usefixtures('app', 'autologin_user')
39 39 @pytest.mark.backends("git", "hg")
40 40 class TestPullrequestsController:
41 41
42 42 def test_index(self, backend):
43 43 self.app.get(url(
44 44 controller='pullrequests', action='index',
45 45 repo_name=backend.repo_name))
46 46
47 47 def test_option_menu_create_pull_request_exists(self, backend):
48 48 repo_name = backend.repo_name
49 49 response = self.app.get(url('summary_home', repo_name=repo_name))
50 50
51 51 create_pr_link = '<a href="%s">Create Pull Request</a>' % url(
52 52 'pullrequest', repo_name=repo_name)
53 53 response.mustcontain(create_pr_link)
54 54
55 55 def test_global_redirect_of_pr(self, backend, pr_util):
56 56 pull_request = pr_util.create_pull_request()
57 57
58 58 response = self.app.get(
59 59 url('pull_requests_global',
60 60 pull_request_id=pull_request.pull_request_id))
61 61
62 62 repo_name = pull_request.target_repo.repo_name
63 63 redirect_url = url('pullrequest_show', repo_name=repo_name,
64 64 pull_request_id=pull_request.pull_request_id)
65 65 assert response.status == '302 Found'
66 66 assert redirect_url in response.location
67 67
68 68 def test_create_pr_form_with_raw_commit_id(self, backend):
69 69 repo = backend.repo
70 70
71 71 self.app.get(
72 72 url(controller='pullrequests', action='index',
73 73 repo_name=repo.repo_name,
74 74 commit=repo.get_commit().raw_id),
75 75 status=200)
76 76
77 77 @pytest.mark.parametrize('pr_merge_enabled', [True, False])
78 78 def test_show(self, pr_util, pr_merge_enabled):
79 79 pull_request = pr_util.create_pull_request(
80 80 mergeable=pr_merge_enabled, enable_notifications=False)
81 81
82 82 response = self.app.get(url(
83 83 controller='pullrequests', action='show',
84 84 repo_name=pull_request.target_repo.scm_instance().name,
85 85 pull_request_id=str(pull_request.pull_request_id)))
86 86
87 87 for commit_id in pull_request.revisions:
88 88 response.mustcontain(commit_id)
89 89
90 90 assert pull_request.target_ref_parts.type in response
91 91 assert pull_request.target_ref_parts.name in response
92 92 target_clone_url = pull_request.target_repo.clone_url()
93 93 assert target_clone_url in response
94 94
95 95 assert 'class="pull-request-merge"' in response
96 96 assert (
97 97 'Server-side pull request merging is disabled.'
98 98 in response) != pr_merge_enabled
99 99
100 100 def test_close_status_visibility(self, pr_util, csrf_token):
101 101 from rhodecode.tests.functional.test_login import login_url, logut_url
102 102 # Logout
103 103 response = self.app.post(
104 104 logut_url,
105 105 params={'csrf_token': csrf_token})
106 106 # Login as regular user
107 107 response = self.app.post(login_url,
108 108 {'username': 'test_regular',
109 109 'password': 'test12'})
110 110
111 111 pull_request = pr_util.create_pull_request(author='test_regular')
112 112
113 113 response = self.app.get(url(
114 114 controller='pullrequests', action='show',
115 115 repo_name=pull_request.target_repo.scm_instance().name,
116 116 pull_request_id=str(pull_request.pull_request_id)))
117 117
118 118 assert 'Server-side pull request merging is disabled.' in response
119 119 assert 'value="forced_closed"' in response
120 120
121 121 def test_show_invalid_commit_id(self, pr_util):
122 122 # Simulating invalid revisions which will cause a lookup error
123 123 pull_request = pr_util.create_pull_request()
124 124 pull_request.revisions = ['invalid']
125 125 Session().add(pull_request)
126 126 Session().commit()
127 127
128 128 response = self.app.get(url(
129 129 controller='pullrequests', action='show',
130 130 repo_name=pull_request.target_repo.scm_instance().name,
131 131 pull_request_id=str(pull_request.pull_request_id)))
132 132
133 133 for commit_id in pull_request.revisions:
134 134 response.mustcontain(commit_id)
135 135
136 136 def test_show_invalid_source_reference(self, pr_util):
137 137 pull_request = pr_util.create_pull_request()
138 138 pull_request.source_ref = 'branch:b:invalid'
139 139 Session().add(pull_request)
140 140 Session().commit()
141 141
142 142 self.app.get(url(
143 143 controller='pullrequests', action='show',
144 144 repo_name=pull_request.target_repo.scm_instance().name,
145 145 pull_request_id=str(pull_request.pull_request_id)))
146 146
147 147 def test_edit_title_description(self, pr_util, csrf_token):
148 148 pull_request = pr_util.create_pull_request()
149 149 pull_request_id = pull_request.pull_request_id
150 150
151 151 response = self.app.post(
152 152 url(controller='pullrequests', action='update',
153 153 repo_name=pull_request.target_repo.repo_name,
154 154 pull_request_id=str(pull_request_id)),
155 155 params={
156 156 'edit_pull_request': 'true',
157 157 '_method': 'put',
158 158 'title': 'New title',
159 159 'description': 'New description',
160 160 'csrf_token': csrf_token})
161 161
162 162 assert_session_flash(
163 163 response, u'Pull request title & description updated.',
164 164 category='success')
165 165
166 166 pull_request = PullRequest.get(pull_request_id)
167 167 assert pull_request.title == 'New title'
168 168 assert pull_request.description == 'New description'
169 169
170 170 def test_edit_title_description_closed(self, pr_util, csrf_token):
171 171 pull_request = pr_util.create_pull_request()
172 172 pull_request_id = pull_request.pull_request_id
173 173 pr_util.close()
174 174
175 175 response = self.app.post(
176 176 url(controller='pullrequests', action='update',
177 177 repo_name=pull_request.target_repo.repo_name,
178 178 pull_request_id=str(pull_request_id)),
179 179 params={
180 180 'edit_pull_request': 'true',
181 181 '_method': 'put',
182 182 'title': 'New title',
183 183 'description': 'New description',
184 184 'csrf_token': csrf_token})
185 185
186 186 assert_session_flash(
187 187 response, u'Cannot update closed pull requests.',
188 188 category='error')
189 189
190 190 def test_update_invalid_source_reference(self, pr_util, csrf_token):
191 191 from rhodecode.lib.vcs.backends.base import UpdateFailureReason
192 192
193 193 pull_request = pr_util.create_pull_request()
194 194 pull_request.source_ref = 'branch:invalid-branch:invalid-commit-id'
195 195 Session().add(pull_request)
196 196 Session().commit()
197 197
198 198 pull_request_id = pull_request.pull_request_id
199 199
200 200 response = self.app.post(
201 201 url(controller='pullrequests', action='update',
202 202 repo_name=pull_request.target_repo.repo_name,
203 203 pull_request_id=str(pull_request_id)),
204 204 params={'update_commits': 'true', '_method': 'put',
205 205 'csrf_token': csrf_token})
206 206
207 207 expected_msg = PullRequestModel.UPDATE_STATUS_MESSAGES[
208 208 UpdateFailureReason.MISSING_SOURCE_REF]
209 209 assert_session_flash(response, expected_msg, category='error')
210 210
211 211 def test_missing_target_reference(self, pr_util, csrf_token):
212 212 from rhodecode.lib.vcs.backends.base import MergeFailureReason
213 213 pull_request = pr_util.create_pull_request(
214 214 approved=True, mergeable=True)
215 215 pull_request.target_ref = 'branch:invalid-branch:invalid-commit-id'
216 216 Session().add(pull_request)
217 217 Session().commit()
218 218
219 219 pull_request_id = pull_request.pull_request_id
220 220 pull_request_url = url(
221 221 controller='pullrequests', action='show',
222 222 repo_name=pull_request.target_repo.repo_name,
223 223 pull_request_id=str(pull_request_id))
224 224
225 225 response = self.app.get(pull_request_url)
226 226
227 227 assertr = AssertResponse(response)
228 228 expected_msg = PullRequestModel.MERGE_STATUS_MESSAGES[
229 229 MergeFailureReason.MISSING_TARGET_REF]
230 230 assertr.element_contains(
231 231 'span[data-role="merge-message"]', str(expected_msg))
232 232
233 233 def test_comment_and_close_pull_request(self, pr_util, csrf_token):
234 234 pull_request = pr_util.create_pull_request(approved=True)
235 235 pull_request_id = pull_request.pull_request_id
236 236 author = pull_request.user_id
237 237 repo = pull_request.target_repo.repo_id
238 238
239 239 self.app.post(
240 240 url(controller='pullrequests',
241 241 action='comment',
242 242 repo_name=pull_request.target_repo.scm_instance().name,
243 243 pull_request_id=str(pull_request_id)),
244 244 params={
245 245 'changeset_status':
246 246 ChangesetStatus.STATUS_APPROVED + '_closed',
247 247 'change_changeset_status': 'on',
248 248 'text': '',
249 249 'csrf_token': csrf_token},
250 250 status=302)
251 251
252 252 action = 'user_closed_pull_request:%d' % pull_request_id
253 253 journal = UserLog.query()\
254 254 .filter(UserLog.user_id == author)\
255 255 .filter(UserLog.repository_id == repo)\
256 256 .filter(UserLog.action == action)\
257 257 .all()
258 258 assert len(journal) == 1
259 259
260 260 def test_reject_and_close_pull_request(self, pr_util, csrf_token):
261 261 pull_request = pr_util.create_pull_request()
262 262 pull_request_id = pull_request.pull_request_id
263 263 response = self.app.post(
264 264 url(controller='pullrequests',
265 265 action='update',
266 266 repo_name=pull_request.target_repo.scm_instance().name,
267 267 pull_request_id=str(pull_request.pull_request_id)),
268 268 params={'close_pull_request': 'true', '_method': 'put',
269 269 'csrf_token': csrf_token})
270 270
271 271 pull_request = PullRequest.get(pull_request_id)
272 272
273 273 assert response.json is True
274 274 assert pull_request.is_closed()
275 275
276 276 # check only the latest status, not the review status
277 277 status = ChangesetStatusModel().get_status(
278 278 pull_request.source_repo, pull_request=pull_request)
279 279 assert status == ChangesetStatus.STATUS_REJECTED
280 280
281 281 def test_comment_force_close_pull_request(self, pr_util, csrf_token):
282 282 pull_request = pr_util.create_pull_request()
283 283 pull_request_id = pull_request.pull_request_id
284 284 reviewers_data = [(1, ['reason']), (2, ['reason2'])]
285 285 PullRequestModel().update_reviewers(pull_request_id, reviewers_data)
286 286 author = pull_request.user_id
287 287 repo = pull_request.target_repo.repo_id
288 288 self.app.post(
289 289 url(controller='pullrequests',
290 290 action='comment',
291 291 repo_name=pull_request.target_repo.scm_instance().name,
292 292 pull_request_id=str(pull_request_id)),
293 293 params={
294 294 'changeset_status': 'forced_closed',
295 295 'csrf_token': csrf_token},
296 296 status=302)
297 297
298 298 pull_request = PullRequest.get(pull_request_id)
299 299
300 300 action = 'user_closed_pull_request:%d' % pull_request_id
301 301 journal = UserLog.query().filter(
302 302 UserLog.user_id == author,
303 303 UserLog.repository_id == repo,
304 304 UserLog.action == action).all()
305 305 assert len(journal) == 1
306 306
307 307 # check only the latest status, not the review status
308 308 status = ChangesetStatusModel().get_status(
309 309 pull_request.source_repo, pull_request=pull_request)
310 310 assert status == ChangesetStatus.STATUS_REJECTED
311 311
312 312 def test_create_pull_request(self, backend, csrf_token):
313 313 commits = [
314 314 {'message': 'ancestor'},
315 315 {'message': 'change'},
316 316 {'message': 'change2'},
317 317 ]
318 318 commit_ids = backend.create_master_repo(commits)
319 319 target = backend.create_repo(heads=['ancestor'])
320 320 source = backend.create_repo(heads=['change2'])
321 321
322 322 response = self.app.post(
323 323 url(
324 324 controller='pullrequests',
325 325 action='create',
326 326 repo_name=source.repo_name
327 327 ),
328 328 [
329 329 ('source_repo', source.repo_name),
330 330 ('source_ref', 'branch:default:' + commit_ids['change2']),
331 331 ('target_repo', target.repo_name),
332 332 ('target_ref', 'branch:default:' + commit_ids['ancestor']),
333 333 ('pullrequest_desc', 'Description'),
334 334 ('pullrequest_title', 'Title'),
335 335 ('__start__', 'review_members:sequence'),
336 336 ('__start__', 'reviewer:mapping'),
337 337 ('user_id', '1'),
338 338 ('__start__', 'reasons:sequence'),
339 339 ('reason', 'Some reason'),
340 340 ('__end__', 'reasons:sequence'),
341 341 ('__end__', 'reviewer:mapping'),
342 342 ('__end__', 'review_members:sequence'),
343 343 ('__start__', 'revisions:sequence'),
344 344 ('revisions', commit_ids['change']),
345 345 ('revisions', commit_ids['change2']),
346 346 ('__end__', 'revisions:sequence'),
347 347 ('user', ''),
348 348 ('csrf_token', csrf_token),
349 349 ],
350 350 status=302)
351 351
352 352 location = response.headers['Location']
353 353 pull_request_id = int(location.rsplit('/', 1)[1])
354 354 pull_request = PullRequest.get(pull_request_id)
355 355
356 356 # check that we have now both revisions
357 357 assert pull_request.revisions == [commit_ids['change2'], commit_ids['change']]
358 358 assert pull_request.source_ref == 'branch:default:' + commit_ids['change2']
359 359 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
360 360 assert pull_request.target_ref == expected_target_ref
361 361
362 362 def test_reviewer_notifications(self, backend, csrf_token):
363 363 # We have to use the app.post for this test so it will create the
364 364 # notifications properly with the new PR
365 365 commits = [
366 366 {'message': 'ancestor',
367 367 'added': [FileNode('file_A', content='content_of_ancestor')]},
368 368 {'message': 'change',
369 369 'added': [FileNode('file_a', content='content_of_change')]},
370 370 {'message': 'change-child'},
371 371 {'message': 'ancestor-child', 'parents': ['ancestor'],
372 372 'added': [
373 373 FileNode('file_B', content='content_of_ancestor_child')]},
374 374 {'message': 'ancestor-child-2'},
375 375 ]
376 376 commit_ids = backend.create_master_repo(commits)
377 377 target = backend.create_repo(heads=['ancestor-child'])
378 378 source = backend.create_repo(heads=['change'])
379 379
380 380 response = self.app.post(
381 381 url(
382 382 controller='pullrequests',
383 383 action='create',
384 384 repo_name=source.repo_name
385 385 ),
386 386 [
387 387 ('source_repo', source.repo_name),
388 388 ('source_ref', 'branch:default:' + commit_ids['change']),
389 389 ('target_repo', target.repo_name),
390 390 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
391 391 ('pullrequest_desc', 'Description'),
392 392 ('pullrequest_title', 'Title'),
393 393 ('__start__', 'review_members:sequence'),
394 394 ('__start__', 'reviewer:mapping'),
395 395 ('user_id', '2'),
396 396 ('__start__', 'reasons:sequence'),
397 397 ('reason', 'Some reason'),
398 398 ('__end__', 'reasons:sequence'),
399 399 ('__end__', 'reviewer:mapping'),
400 400 ('__end__', 'review_members:sequence'),
401 401 ('__start__', 'revisions:sequence'),
402 402 ('revisions', commit_ids['change']),
403 403 ('__end__', 'revisions:sequence'),
404 404 ('user', ''),
405 405 ('csrf_token', csrf_token),
406 406 ],
407 407 status=302)
408 408
409 409 location = response.headers['Location']
410 410 pull_request_id = int(location.rsplit('/', 1)[1])
411 411 pull_request = PullRequest.get(pull_request_id)
412 412
413 413 # Check that a notification was made
414 414 notifications = Notification.query()\
415 415 .filter(Notification.created_by == pull_request.author.user_id,
416 416 Notification.type_ == Notification.TYPE_PULL_REQUEST,
417 417 Notification.subject.contains("wants you to review "
418 418 "pull request #%d"
419 419 % pull_request_id))
420 420 assert len(notifications.all()) == 1
421 421
422 422 # Change reviewers and check that a notification was made
423 423 PullRequestModel().update_reviewers(
424 424 pull_request.pull_request_id, [(1, [])])
425 425 assert len(notifications.all()) == 2
426 426
427 427 def test_create_pull_request_stores_ancestor_commit_id(self, backend,
428 428 csrf_token):
429 429 commits = [
430 430 {'message': 'ancestor',
431 431 'added': [FileNode('file_A', content='content_of_ancestor')]},
432 432 {'message': 'change',
433 433 'added': [FileNode('file_a', content='content_of_change')]},
434 434 {'message': 'change-child'},
435 435 {'message': 'ancestor-child', 'parents': ['ancestor'],
436 436 'added': [
437 437 FileNode('file_B', content='content_of_ancestor_child')]},
438 438 {'message': 'ancestor-child-2'},
439 439 ]
440 440 commit_ids = backend.create_master_repo(commits)
441 441 target = backend.create_repo(heads=['ancestor-child'])
442 442 source = backend.create_repo(heads=['change'])
443 443
444 444 response = self.app.post(
445 445 url(
446 446 controller='pullrequests',
447 447 action='create',
448 448 repo_name=source.repo_name
449 449 ),
450 450 [
451 451 ('source_repo', source.repo_name),
452 452 ('source_ref', 'branch:default:' + commit_ids['change']),
453 453 ('target_repo', target.repo_name),
454 454 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
455 455 ('pullrequest_desc', 'Description'),
456 456 ('pullrequest_title', 'Title'),
457 457 ('__start__', 'review_members:sequence'),
458 458 ('__start__', 'reviewer:mapping'),
459 459 ('user_id', '1'),
460 460 ('__start__', 'reasons:sequence'),
461 461 ('reason', 'Some reason'),
462 462 ('__end__', 'reasons:sequence'),
463 463 ('__end__', 'reviewer:mapping'),
464 464 ('__end__', 'review_members:sequence'),
465 465 ('__start__', 'revisions:sequence'),
466 466 ('revisions', commit_ids['change']),
467 467 ('__end__', 'revisions:sequence'),
468 468 ('user', ''),
469 469 ('csrf_token', csrf_token),
470 470 ],
471 471 status=302)
472 472
473 473 location = response.headers['Location']
474 474 pull_request_id = int(location.rsplit('/', 1)[1])
475 475 pull_request = PullRequest.get(pull_request_id)
476 476
477 477 # target_ref has to point to the ancestor's commit_id in order to
478 478 # show the correct diff
479 479 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
480 480 assert pull_request.target_ref == expected_target_ref
481 481
482 482 # Check generated diff contents
483 483 response = response.follow()
484 484 assert 'content_of_ancestor' not in response.body
485 485 assert 'content_of_ancestor-child' not in response.body
486 486 assert 'content_of_change' in response.body
487 487
488 488 def test_merge_pull_request_enabled(self, pr_util, csrf_token):
489 489 # Clear any previous calls to rcextensions
490 490 rhodecode.EXTENSIONS.calls.clear()
491 491
492 492 pull_request = pr_util.create_pull_request(
493 493 approved=True, mergeable=True)
494 494 pull_request_id = pull_request.pull_request_id
495 495 repo_name = pull_request.target_repo.scm_instance().name,
496 496
497 497 response = self.app.post(
498 498 url(controller='pullrequests',
499 499 action='merge',
500 500 repo_name=str(repo_name[0]),
501 501 pull_request_id=str(pull_request_id)),
502 502 params={'csrf_token': csrf_token}).follow()
503 503
504 504 pull_request = PullRequest.get(pull_request_id)
505 505
506 506 assert response.status_int == 200
507 507 assert pull_request.is_closed()
508 508 assert_pull_request_status(
509 509 pull_request, ChangesetStatus.STATUS_APPROVED)
510 510
511 511 # Check the relevant log entries were added
512 512 user_logs = UserLog.query().order_by('-user_log_id').limit(4)
513 513 actions = [log.action for log in user_logs]
514 514 pr_commit_ids = PullRequestModel()._get_commit_ids(pull_request)
515 515 expected_actions = [
516 516 u'user_closed_pull_request:%d' % pull_request_id,
517 517 u'user_merged_pull_request:%d' % pull_request_id,
518 518 # The action below reflect that the post push actions were executed
519 519 u'user_commented_pull_request:%d' % pull_request_id,
520 520 u'push:%s' % ','.join(pr_commit_ids),
521 521 ]
522 522 assert actions == expected_actions
523 523
524 524 # Check post_push rcextension was really executed
525 525 push_calls = rhodecode.EXTENSIONS.calls['post_push']
526 526 assert len(push_calls) == 1
527 527 unused_last_call_args, last_call_kwargs = push_calls[0]
528 528 assert last_call_kwargs['action'] == 'push'
529 529 assert last_call_kwargs['pushed_revs'] == pr_commit_ids
530 530
531 531 def test_merge_pull_request_disabled(self, pr_util, csrf_token):
532 532 pull_request = pr_util.create_pull_request(mergeable=False)
533 533 pull_request_id = pull_request.pull_request_id
534 534 pull_request = PullRequest.get(pull_request_id)
535 535
536 536 response = self.app.post(
537 537 url(controller='pullrequests',
538 538 action='merge',
539 539 repo_name=pull_request.target_repo.scm_instance().name,
540 540 pull_request_id=str(pull_request.pull_request_id)),
541 541 params={'csrf_token': csrf_token}).follow()
542 542
543 543 assert response.status_int == 200
544 assert 'Server-side pull request merging is disabled.' in response.body
544 response.mustcontain(
545 'Merge is not currently possible because of below failed checks.')
546 response.mustcontain('Server-side pull request merging is disabled.')
545 547
546 548 @pytest.mark.skip_backends('svn')
547 549 def test_merge_pull_request_not_approved(self, pr_util, csrf_token):
548 550 pull_request = pr_util.create_pull_request(mergeable=True)
549 551 pull_request_id = pull_request.pull_request_id
550 552 repo_name = pull_request.target_repo.scm_instance().name,
551 553
552 554 response = self.app.post(
553 555 url(controller='pullrequests',
554 556 action='merge',
555 557 repo_name=str(repo_name[0]),
556 558 pull_request_id=str(pull_request_id)),
557 559 params={'csrf_token': csrf_token}).follow()
558 560
559 pull_request = PullRequest.get(pull_request_id)
561 assert response.status_int == 200
560 562
561 assert response.status_int == 200
562 assert ' Reviewer approval is pending.' in response.body
563 response.mustcontain(
564 'Merge is not currently possible because of below failed checks.')
565 response.mustcontain('Pull request reviewer approval is pending.')
563 566
564 567 def test_update_source_revision(self, backend, csrf_token):
565 568 commits = [
566 569 {'message': 'ancestor'},
567 570 {'message': 'change'},
568 571 {'message': 'change-2'},
569 572 ]
570 573 commit_ids = backend.create_master_repo(commits)
571 574 target = backend.create_repo(heads=['ancestor'])
572 575 source = backend.create_repo(heads=['change'])
573 576
574 577 # create pr from a in source to A in target
575 578 pull_request = PullRequest()
576 579 pull_request.source_repo = source
577 580 # TODO: johbo: Make sure that we write the source ref this way!
578 581 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
579 582 branch=backend.default_branch_name, commit_id=commit_ids['change'])
580 583 pull_request.target_repo = target
581 584
582 585 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
583 586 branch=backend.default_branch_name,
584 587 commit_id=commit_ids['ancestor'])
585 588 pull_request.revisions = [commit_ids['change']]
586 589 pull_request.title = u"Test"
587 590 pull_request.description = u"Description"
588 591 pull_request.author = UserModel().get_by_username(
589 592 TEST_USER_ADMIN_LOGIN)
590 593 Session().add(pull_request)
591 594 Session().commit()
592 595 pull_request_id = pull_request.pull_request_id
593 596
594 597 # source has ancestor - change - change-2
595 598 backend.pull_heads(source, heads=['change-2'])
596 599
597 600 # update PR
598 601 self.app.post(
599 602 url(controller='pullrequests', action='update',
600 603 repo_name=target.repo_name,
601 604 pull_request_id=str(pull_request_id)),
602 605 params={'update_commits': 'true', '_method': 'put',
603 606 'csrf_token': csrf_token})
604 607
605 608 # check that we have now both revisions
606 609 pull_request = PullRequest.get(pull_request_id)
607 610 assert pull_request.revisions == [
608 611 commit_ids['change-2'], commit_ids['change']]
609 612
610 613 # TODO: johbo: this should be a test on its own
611 614 response = self.app.get(url(
612 615 controller='pullrequests', action='index',
613 616 repo_name=target.repo_name))
614 617 assert response.status_int == 200
615 618 assert 'Pull request updated to' in response.body
616 619 assert 'with 1 added, 0 removed commits.' in response.body
617 620
618 621 def test_update_target_revision(self, backend, csrf_token):
619 622 commits = [
620 623 {'message': 'ancestor'},
621 624 {'message': 'change'},
622 625 {'message': 'ancestor-new', 'parents': ['ancestor']},
623 626 {'message': 'change-rebased'},
624 627 ]
625 628 commit_ids = backend.create_master_repo(commits)
626 629 target = backend.create_repo(heads=['ancestor'])
627 630 source = backend.create_repo(heads=['change'])
628 631
629 632 # create pr from a in source to A in target
630 633 pull_request = PullRequest()
631 634 pull_request.source_repo = source
632 635 # TODO: johbo: Make sure that we write the source ref this way!
633 636 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
634 637 branch=backend.default_branch_name, commit_id=commit_ids['change'])
635 638 pull_request.target_repo = target
636 639 # TODO: johbo: Target ref should be branch based, since tip can jump
637 640 # from branch to branch
638 641 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
639 642 branch=backend.default_branch_name,
640 643 commit_id=commit_ids['ancestor'])
641 644 pull_request.revisions = [commit_ids['change']]
642 645 pull_request.title = u"Test"
643 646 pull_request.description = u"Description"
644 647 pull_request.author = UserModel().get_by_username(
645 648 TEST_USER_ADMIN_LOGIN)
646 649 Session().add(pull_request)
647 650 Session().commit()
648 651 pull_request_id = pull_request.pull_request_id
649 652
650 653 # target has ancestor - ancestor-new
651 654 # source has ancestor - ancestor-new - change-rebased
652 655 backend.pull_heads(target, heads=['ancestor-new'])
653 656 backend.pull_heads(source, heads=['change-rebased'])
654 657
655 658 # update PR
656 659 self.app.post(
657 660 url(controller='pullrequests', action='update',
658 661 repo_name=target.repo_name,
659 662 pull_request_id=str(pull_request_id)),
660 663 params={'update_commits': 'true', '_method': 'put',
661 664 'csrf_token': csrf_token},
662 665 status=200)
663 666
664 667 # check that we have now both revisions
665 668 pull_request = PullRequest.get(pull_request_id)
666 669 assert pull_request.revisions == [commit_ids['change-rebased']]
667 670 assert pull_request.target_ref == 'branch:{branch}:{commit_id}'.format(
668 671 branch=backend.default_branch_name,
669 672 commit_id=commit_ids['ancestor-new'])
670 673
671 674 # TODO: johbo: This should be a test on its own
672 675 response = self.app.get(url(
673 676 controller='pullrequests', action='index',
674 677 repo_name=target.repo_name))
675 678 assert response.status_int == 200
676 679 assert 'Pull request updated to' in response.body
677 680 assert 'with 1 added, 1 removed commits.' in response.body
678 681
679 682 def test_update_of_ancestor_reference(self, backend, csrf_token):
680 683 commits = [
681 684 {'message': 'ancestor'},
682 685 {'message': 'change'},
683 686 {'message': 'change-2'},
684 687 {'message': 'ancestor-new', 'parents': ['ancestor']},
685 688 {'message': 'change-rebased'},
686 689 ]
687 690 commit_ids = backend.create_master_repo(commits)
688 691 target = backend.create_repo(heads=['ancestor'])
689 692 source = backend.create_repo(heads=['change'])
690 693
691 694 # create pr from a in source to A in target
692 695 pull_request = PullRequest()
693 696 pull_request.source_repo = source
694 697 # TODO: johbo: Make sure that we write the source ref this way!
695 698 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
696 699 branch=backend.default_branch_name,
697 700 commit_id=commit_ids['change'])
698 701 pull_request.target_repo = target
699 702 # TODO: johbo: Target ref should be branch based, since tip can jump
700 703 # from branch to branch
701 704 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
702 705 branch=backend.default_branch_name,
703 706 commit_id=commit_ids['ancestor'])
704 707 pull_request.revisions = [commit_ids['change']]
705 708 pull_request.title = u"Test"
706 709 pull_request.description = u"Description"
707 710 pull_request.author = UserModel().get_by_username(
708 711 TEST_USER_ADMIN_LOGIN)
709 712 Session().add(pull_request)
710 713 Session().commit()
711 714 pull_request_id = pull_request.pull_request_id
712 715
713 716 # target has ancestor - ancestor-new
714 717 # source has ancestor - ancestor-new - change-rebased
715 718 backend.pull_heads(target, heads=['ancestor-new'])
716 719 backend.pull_heads(source, heads=['change-rebased'])
717 720
718 721 # update PR
719 722 self.app.post(
720 723 url(controller='pullrequests', action='update',
721 724 repo_name=target.repo_name,
722 725 pull_request_id=str(pull_request_id)),
723 726 params={'update_commits': 'true', '_method': 'put',
724 727 'csrf_token': csrf_token},
725 728 status=200)
726 729
727 730 # Expect the target reference to be updated correctly
728 731 pull_request = PullRequest.get(pull_request_id)
729 732 assert pull_request.revisions == [commit_ids['change-rebased']]
730 733 expected_target_ref = 'branch:{branch}:{commit_id}'.format(
731 734 branch=backend.default_branch_name,
732 735 commit_id=commit_ids['ancestor-new'])
733 736 assert pull_request.target_ref == expected_target_ref
734 737
735 738 def test_remove_pull_request_branch(self, backend_git, csrf_token):
736 739 branch_name = 'development'
737 740 commits = [
738 741 {'message': 'initial-commit'},
739 742 {'message': 'old-feature'},
740 743 {'message': 'new-feature', 'branch': branch_name},
741 744 ]
742 745 repo = backend_git.create_repo(commits)
743 746 commit_ids = backend_git.commit_ids
744 747
745 748 pull_request = PullRequest()
746 749 pull_request.source_repo = repo
747 750 pull_request.target_repo = repo
748 751 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
749 752 branch=branch_name, commit_id=commit_ids['new-feature'])
750 753 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
751 754 branch=backend_git.default_branch_name,
752 755 commit_id=commit_ids['old-feature'])
753 756 pull_request.revisions = [commit_ids['new-feature']]
754 757 pull_request.title = u"Test"
755 758 pull_request.description = u"Description"
756 759 pull_request.author = UserModel().get_by_username(
757 760 TEST_USER_ADMIN_LOGIN)
758 761 Session().add(pull_request)
759 762 Session().commit()
760 763
761 764 vcs = repo.scm_instance()
762 765 vcs.remove_ref('refs/heads/{}'.format(branch_name))
763 766
764 767 response = self.app.get(url(
765 768 controller='pullrequests', action='show',
766 769 repo_name=repo.repo_name,
767 770 pull_request_id=str(pull_request.pull_request_id)))
768 771
769 772 assert response.status_int == 200
770 773 assert_response = AssertResponse(response)
771 774 assert_response.element_contains(
772 775 '#changeset_compare_view_content .alert strong',
773 776 'Missing commits')
774 777 assert_response.element_contains(
775 778 '#changeset_compare_view_content .alert',
776 779 'This pull request cannot be displayed, because one or more'
777 780 ' commits no longer exist in the source repository.')
778 781
779 782 def test_strip_commits_from_pull_request(
780 783 self, backend, pr_util, csrf_token):
781 784 commits = [
782 785 {'message': 'initial-commit'},
783 786 {'message': 'old-feature'},
784 787 {'message': 'new-feature', 'parents': ['initial-commit']},
785 788 ]
786 789 pull_request = pr_util.create_pull_request(
787 790 commits, target_head='initial-commit', source_head='new-feature',
788 791 revisions=['new-feature'])
789 792
790 793 vcs = pr_util.source_repository.scm_instance()
791 794 if backend.alias == 'git':
792 795 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
793 796 else:
794 797 vcs.strip(pr_util.commit_ids['new-feature'])
795 798
796 799 response = self.app.get(url(
797 800 controller='pullrequests', action='show',
798 801 repo_name=pr_util.target_repository.repo_name,
799 802 pull_request_id=str(pull_request.pull_request_id)))
800 803
801 804 assert response.status_int == 200
802 805 assert_response = AssertResponse(response)
803 806 assert_response.element_contains(
804 807 '#changeset_compare_view_content .alert strong',
805 808 'Missing commits')
806 809 assert_response.element_contains(
807 810 '#changeset_compare_view_content .alert',
808 811 'This pull request cannot be displayed, because one or more'
809 812 ' commits no longer exist in the source repository.')
810 813 assert_response.element_contains(
811 814 '#update_commits',
812 815 'Update commits')
813 816
814 817 def test_strip_commits_and_update(
815 818 self, backend, pr_util, csrf_token):
816 819 commits = [
817 820 {'message': 'initial-commit'},
818 821 {'message': 'old-feature'},
819 822 {'message': 'new-feature', 'parents': ['old-feature']},
820 823 ]
821 824 pull_request = pr_util.create_pull_request(
822 825 commits, target_head='old-feature', source_head='new-feature',
823 826 revisions=['new-feature'], mergeable=True)
824 827
825 828 vcs = pr_util.source_repository.scm_instance()
826 829 if backend.alias == 'git':
827 830 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
828 831 else:
829 832 vcs.strip(pr_util.commit_ids['new-feature'])
830 833
831 834 response = self.app.post(
832 835 url(controller='pullrequests', action='update',
833 836 repo_name=pull_request.target_repo.repo_name,
834 837 pull_request_id=str(pull_request.pull_request_id)),
835 838 params={'update_commits': 'true', '_method': 'put',
836 839 'csrf_token': csrf_token})
837 840
838 841 assert response.status_int == 200
839 842 assert response.body == 'true'
840 843
841 844 # Make sure that after update, it won't raise 500 errors
842 845 response = self.app.get(url(
843 846 controller='pullrequests', action='show',
844 847 repo_name=pr_util.target_repository.repo_name,
845 848 pull_request_id=str(pull_request.pull_request_id)))
846 849
847 850 assert response.status_int == 200
848 851 assert_response = AssertResponse(response)
849 852 assert_response.element_contains(
850 853 '#changeset_compare_view_content .alert strong',
851 854 'Missing commits')
852 855
853 856 def test_branch_is_a_link(self, pr_util):
854 857 pull_request = pr_util.create_pull_request()
855 858 pull_request.source_ref = 'branch:origin:1234567890abcdef'
856 859 pull_request.target_ref = 'branch:target:abcdef1234567890'
857 860 Session().add(pull_request)
858 861 Session().commit()
859 862
860 863 response = self.app.get(url(
861 864 controller='pullrequests', action='show',
862 865 repo_name=pull_request.target_repo.scm_instance().name,
863 866 pull_request_id=str(pull_request.pull_request_id)))
864 867 assert response.status_int == 200
865 868 assert_response = AssertResponse(response)
866 869
867 870 origin = assert_response.get_element('.pr-origininfo .tag')
868 871 origin_children = origin.getchildren()
869 872 assert len(origin_children) == 1
870 873 target = assert_response.get_element('.pr-targetinfo .tag')
871 874 target_children = target.getchildren()
872 875 assert len(target_children) == 1
873 876
874 877 expected_origin_link = url(
875 878 'changelog_home',
876 879 repo_name=pull_request.source_repo.scm_instance().name,
877 880 branch='origin')
878 881 expected_target_link = url(
879 882 'changelog_home',
880 883 repo_name=pull_request.target_repo.scm_instance().name,
881 884 branch='target')
882 885 assert origin_children[0].attrib['href'] == expected_origin_link
883 886 assert origin_children[0].text == 'branch: origin'
884 887 assert target_children[0].attrib['href'] == expected_target_link
885 888 assert target_children[0].text == 'branch: target'
886 889
887 890 def test_bookmark_is_not_a_link(self, pr_util):
888 891 pull_request = pr_util.create_pull_request()
889 892 pull_request.source_ref = 'bookmark:origin:1234567890abcdef'
890 893 pull_request.target_ref = 'bookmark:target:abcdef1234567890'
891 894 Session().add(pull_request)
892 895 Session().commit()
893 896
894 897 response = self.app.get(url(
895 898 controller='pullrequests', action='show',
896 899 repo_name=pull_request.target_repo.scm_instance().name,
897 900 pull_request_id=str(pull_request.pull_request_id)))
898 901 assert response.status_int == 200
899 902 assert_response = AssertResponse(response)
900 903
901 904 origin = assert_response.get_element('.pr-origininfo .tag')
902 905 assert origin.text.strip() == 'bookmark: origin'
903 906 assert origin.getchildren() == []
904 907
905 908 target = assert_response.get_element('.pr-targetinfo .tag')
906 909 assert target.text.strip() == 'bookmark: target'
907 910 assert target.getchildren() == []
908 911
909 912 def test_tag_is_not_a_link(self, pr_util):
910 913 pull_request = pr_util.create_pull_request()
911 914 pull_request.source_ref = 'tag:origin:1234567890abcdef'
912 915 pull_request.target_ref = 'tag:target:abcdef1234567890'
913 916 Session().add(pull_request)
914 917 Session().commit()
915 918
916 919 response = self.app.get(url(
917 920 controller='pullrequests', action='show',
918 921 repo_name=pull_request.target_repo.scm_instance().name,
919 922 pull_request_id=str(pull_request.pull_request_id)))
920 923 assert response.status_int == 200
921 924 assert_response = AssertResponse(response)
922 925
923 926 origin = assert_response.get_element('.pr-origininfo .tag')
924 927 assert origin.text.strip() == 'tag: origin'
925 928 assert origin.getchildren() == []
926 929
927 930 target = assert_response.get_element('.pr-targetinfo .tag')
928 931 assert target.text.strip() == 'tag: target'
929 932 assert target.getchildren() == []
930 933
931 934 def test_description_is_escaped_on_index_page(self, backend, pr_util):
932 935 xss_description = "<script>alert('Hi!')</script>"
933 936 pull_request = pr_util.create_pull_request(description=xss_description)
934 937 response = self.app.get(url(
935 938 controller='pullrequests', action='show_all',
936 939 repo_name=pull_request.target_repo.repo_name))
937 940 response.mustcontain(
938 941 "&lt;script&gt;alert(&#39;Hi!&#39;)&lt;/script&gt;")
939 942
940 943 @pytest.mark.parametrize('mergeable', [True, False])
941 944 def test_shadow_repository_link(
942 945 self, mergeable, pr_util, http_host_stub):
943 946 """
944 947 Check that the pull request summary page displays a link to the shadow
945 948 repository if the pull request is mergeable. If it is not mergeable
946 949 the link should not be displayed.
947 950 """
948 951 pull_request = pr_util.create_pull_request(
949 952 mergeable=mergeable, enable_notifications=False)
950 953 target_repo = pull_request.target_repo.scm_instance()
951 954 pr_id = pull_request.pull_request_id
952 955 shadow_url = '{host}/{repo}/pull-request/{pr_id}/repository'.format(
953 956 host=http_host_stub, repo=target_repo.name, pr_id=pr_id)
954 957
955 958 response = self.app.get(url(
956 959 controller='pullrequests', action='show',
957 960 repo_name=target_repo.name,
958 961 pull_request_id=str(pr_id)))
959 962
960 963 assertr = AssertResponse(response)
961 964 if mergeable:
962 965 assertr.element_value_contains(
963 966 'div.pr-mergeinfo input', shadow_url)
964 967 assertr.element_value_contains(
965 968 'div.pr-mergeinfo input', 'pr-merge')
966 969 else:
967 970 assertr.no_element_exists('div.pr-mergeinfo')
968 971
969 972
970 973 @pytest.mark.usefixtures('app')
971 974 @pytest.mark.backends("git", "hg")
972 975 class TestPullrequestsControllerDelete(object):
973 976 def test_pull_request_delete_button_permissions_admin(
974 977 self, autologin_user, user_admin, pr_util):
975 978 pull_request = pr_util.create_pull_request(
976 979 author=user_admin.username, enable_notifications=False)
977 980
978 981 response = self.app.get(url(
979 982 controller='pullrequests', action='show',
980 983 repo_name=pull_request.target_repo.scm_instance().name,
981 984 pull_request_id=str(pull_request.pull_request_id)))
982 985
983 986 response.mustcontain('id="delete_pullrequest"')
984 987 response.mustcontain('Confirm to delete this pull request')
985 988
986 989 def test_pull_request_delete_button_permissions_owner(
987 990 self, autologin_regular_user, user_regular, pr_util):
988 991 pull_request = pr_util.create_pull_request(
989 992 author=user_regular.username, enable_notifications=False)
990 993
991 994 response = self.app.get(url(
992 995 controller='pullrequests', action='show',
993 996 repo_name=pull_request.target_repo.scm_instance().name,
994 997 pull_request_id=str(pull_request.pull_request_id)))
995 998
996 999 response.mustcontain('id="delete_pullrequest"')
997 1000 response.mustcontain('Confirm to delete this pull request')
998 1001
999 1002 def test_pull_request_delete_button_permissions_forbidden(
1000 1003 self, autologin_regular_user, user_regular, user_admin, pr_util):
1001 1004 pull_request = pr_util.create_pull_request(
1002 1005 author=user_admin.username, enable_notifications=False)
1003 1006
1004 1007 response = self.app.get(url(
1005 1008 controller='pullrequests', action='show',
1006 1009 repo_name=pull_request.target_repo.scm_instance().name,
1007 1010 pull_request_id=str(pull_request.pull_request_id)))
1008 1011 response.mustcontain(no=['id="delete_pullrequest"'])
1009 1012 response.mustcontain(no=['Confirm to delete this pull request'])
1010 1013
1011 1014 def test_pull_request_delete_button_permissions_can_update_cannot_delete(
1012 1015 self, autologin_regular_user, user_regular, user_admin, pr_util,
1013 1016 user_util):
1014 1017
1015 1018 pull_request = pr_util.create_pull_request(
1016 1019 author=user_admin.username, enable_notifications=False)
1017 1020
1018 1021 user_util.grant_user_permission_to_repo(
1019 1022 pull_request.target_repo, user_regular,
1020 1023 'repository.write')
1021 1024
1022 1025 response = self.app.get(url(
1023 1026 controller='pullrequests', action='show',
1024 1027 repo_name=pull_request.target_repo.scm_instance().name,
1025 1028 pull_request_id=str(pull_request.pull_request_id)))
1026 1029
1027 1030 response.mustcontain('id="open_edit_pullrequest"')
1028 1031 response.mustcontain('id="delete_pullrequest"')
1029 1032 response.mustcontain(no=['Confirm to delete this pull request'])
1030 1033
1031 1034
1032 1035 def assert_pull_request_status(pull_request, expected_status):
1033 1036 status = ChangesetStatusModel().calculated_review_status(
1034 1037 pull_request=pull_request)
1035 1038 assert status == expected_status
1036 1039
1037 1040
1038 1041 @pytest.mark.parametrize('action', ['show_all', 'index', 'create'])
1039 1042 @pytest.mark.usefixtures("autologin_user")
1040 1043 def test_redirects_to_repo_summary_for_svn_repositories(
1041 1044 backend_svn, app, action):
1042 1045 denied_actions = ['show_all', 'index', 'create']
1043 1046 for action in denied_actions:
1044 1047 response = app.get(url(
1045 1048 controller='pullrequests', action=action,
1046 1049 repo_name=backend_svn.repo_name))
1047 1050 assert response.status_int == 302
1048 1051
1049 1052 # Not allowed, redirect to the summary
1050 1053 redirected = response.follow()
1051 1054 summary_url = url('summary_home', repo_name=backend_svn.repo_name)
1052 1055
1053 1056 # URL adds leading slash and path doesn't have it
1054 1057 assert redirected.req.path == summary_url
1055 1058
1056 1059
1057 1060 def test_delete_comment_returns_404_if_comment_does_not_exist(pylonsapp):
1058 1061 # TODO: johbo: Global import not possible because models.forms blows up
1059 1062 from rhodecode.controllers.pullrequests import PullrequestsController
1060 1063 controller = PullrequestsController()
1061 1064 patcher = mock.patch(
1062 1065 'rhodecode.model.db.BaseModel.get', return_value=None)
1063 1066 with pytest.raises(HTTPNotFound), patcher:
1064 1067 controller._delete_comment(1)
General Comments 0
You need to be logged in to leave comments. Login now