##// END OF EJS Templates
pull-requests: trigger merge simulation during PR creation. Fixes #5396
marcink -
r2168:41032fb6 default
parent child Browse files
Show More
@@ -1,779 +1,780 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 import events
25 25 from rhodecode.api import jsonrpc_method, JSONRPCError, JSONRPCValidationError
26 26 from rhodecode.api.utils import (
27 27 has_superadmin_permission, Optional, OAttr, get_repo_or_error,
28 28 get_pull_request_or_error, get_commit_or_error, get_user_or_error,
29 29 validate_repo_permissions, resolve_ref_or_error)
30 30 from rhodecode.lib.auth import (HasRepoPermissionAnyApi)
31 31 from rhodecode.lib.base import vcs_operation_context
32 32 from rhodecode.lib.utils2 import str2bool
33 33 from rhodecode.model.changeset_status import ChangesetStatusModel
34 34 from rhodecode.model.comment import CommentsModel
35 35 from rhodecode.model.db import Session, ChangesetStatus, ChangesetComment
36 36 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
37 37 from rhodecode.model.settings import SettingsModel
38 38 from rhodecode.model.validation_schema import Invalid
39 39 from rhodecode.model.validation_schema.schemas.reviewer_schema import(
40 40 ReviewerListSchema)
41 41
42 42 log = logging.getLogger(__name__)
43 43
44 44
45 45 @jsonrpc_method()
46 46 def get_pull_request(request, apiuser, repoid, pullrequestid):
47 47 """
48 48 Get a pull request based on the given ID.
49 49
50 50 :param apiuser: This is filled automatically from the |authtoken|.
51 51 :type apiuser: AuthUser
52 52 :param repoid: Repository name or repository ID from where the pull
53 53 request was opened.
54 54 :type repoid: str or int
55 55 :param pullrequestid: ID of the requested pull request.
56 56 :type pullrequestid: int
57 57
58 58 Example output:
59 59
60 60 .. code-block:: bash
61 61
62 62 "id": <id_given_in_input>,
63 63 "result":
64 64 {
65 65 "pull_request_id": "<pull_request_id>",
66 66 "url": "<url>",
67 67 "title": "<title>",
68 68 "description": "<description>",
69 69 "status" : "<status>",
70 70 "created_on": "<date_time_created>",
71 71 "updated_on": "<date_time_updated>",
72 72 "commit_ids": [
73 73 ...
74 74 "<commit_id>",
75 75 "<commit_id>",
76 76 ...
77 77 ],
78 78 "review_status": "<review_status>",
79 79 "mergeable": {
80 80 "status": "<bool>",
81 81 "message": "<message>",
82 82 },
83 83 "source": {
84 84 "clone_url": "<clone_url>",
85 85 "repository": "<repository_name>",
86 86 "reference":
87 87 {
88 88 "name": "<name>",
89 89 "type": "<type>",
90 90 "commit_id": "<commit_id>",
91 91 }
92 92 },
93 93 "target": {
94 94 "clone_url": "<clone_url>",
95 95 "repository": "<repository_name>",
96 96 "reference":
97 97 {
98 98 "name": "<name>",
99 99 "type": "<type>",
100 100 "commit_id": "<commit_id>",
101 101 }
102 102 },
103 103 "merge": {
104 104 "clone_url": "<clone_url>",
105 105 "reference":
106 106 {
107 107 "name": "<name>",
108 108 "type": "<type>",
109 109 "commit_id": "<commit_id>",
110 110 }
111 111 },
112 112 "author": <user_obj>,
113 113 "reviewers": [
114 114 ...
115 115 {
116 116 "user": "<user_obj>",
117 117 "review_status": "<review_status>",
118 118 }
119 119 ...
120 120 ]
121 121 },
122 122 "error": null
123 123 """
124 124 get_repo_or_error(repoid)
125 125 pull_request = get_pull_request_or_error(pullrequestid)
126 126 if not PullRequestModel().check_user_read(
127 127 pull_request, apiuser, api=True):
128 128 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
129 129 data = pull_request.get_api_data()
130 130 return data
131 131
132 132
133 133 @jsonrpc_method()
134 134 def get_pull_requests(request, apiuser, repoid, status=Optional('new')):
135 135 """
136 136 Get all pull requests from the repository specified in `repoid`.
137 137
138 138 :param apiuser: This is filled automatically from the |authtoken|.
139 139 :type apiuser: AuthUser
140 140 :param repoid: Repository name or repository ID.
141 141 :type repoid: str or int
142 142 :param status: Only return pull requests with the specified status.
143 143 Valid options are.
144 144 * ``new`` (default)
145 145 * ``open``
146 146 * ``closed``
147 147 :type status: str
148 148
149 149 Example output:
150 150
151 151 .. code-block:: bash
152 152
153 153 "id": <id_given_in_input>,
154 154 "result":
155 155 [
156 156 ...
157 157 {
158 158 "pull_request_id": "<pull_request_id>",
159 159 "url": "<url>",
160 160 "title" : "<title>",
161 161 "description": "<description>",
162 162 "status": "<status>",
163 163 "created_on": "<date_time_created>",
164 164 "updated_on": "<date_time_updated>",
165 165 "commit_ids": [
166 166 ...
167 167 "<commit_id>",
168 168 "<commit_id>",
169 169 ...
170 170 ],
171 171 "review_status": "<review_status>",
172 172 "mergeable": {
173 173 "status": "<bool>",
174 174 "message: "<message>",
175 175 },
176 176 "source": {
177 177 "clone_url": "<clone_url>",
178 178 "reference":
179 179 {
180 180 "name": "<name>",
181 181 "type": "<type>",
182 182 "commit_id": "<commit_id>",
183 183 }
184 184 },
185 185 "target": {
186 186 "clone_url": "<clone_url>",
187 187 "reference":
188 188 {
189 189 "name": "<name>",
190 190 "type": "<type>",
191 191 "commit_id": "<commit_id>",
192 192 }
193 193 },
194 194 "merge": {
195 195 "clone_url": "<clone_url>",
196 196 "reference":
197 197 {
198 198 "name": "<name>",
199 199 "type": "<type>",
200 200 "commit_id": "<commit_id>",
201 201 }
202 202 },
203 203 "author": <user_obj>,
204 204 "reviewers": [
205 205 ...
206 206 {
207 207 "user": "<user_obj>",
208 208 "review_status": "<review_status>",
209 209 }
210 210 ...
211 211 ]
212 212 }
213 213 ...
214 214 ],
215 215 "error": null
216 216
217 217 """
218 218 repo = get_repo_or_error(repoid)
219 219 if not has_superadmin_permission(apiuser):
220 220 _perms = (
221 221 'repository.admin', 'repository.write', 'repository.read',)
222 222 validate_repo_permissions(apiuser, repoid, repo, _perms)
223 223
224 224 status = Optional.extract(status)
225 225 pull_requests = PullRequestModel().get_all(repo, statuses=[status])
226 226 data = [pr.get_api_data() for pr in pull_requests]
227 227 return data
228 228
229 229
230 230 @jsonrpc_method()
231 231 def merge_pull_request(
232 232 request, apiuser, repoid, pullrequestid,
233 233 userid=Optional(OAttr('apiuser'))):
234 234 """
235 235 Merge the pull request specified by `pullrequestid` into its target
236 236 repository.
237 237
238 238 :param apiuser: This is filled automatically from the |authtoken|.
239 239 :type apiuser: AuthUser
240 240 :param repoid: The Repository name or repository ID of the
241 241 target repository to which the |pr| is to be merged.
242 242 :type repoid: str or int
243 243 :param pullrequestid: ID of the pull request which shall be merged.
244 244 :type pullrequestid: int
245 245 :param userid: Merge the pull request as this user.
246 246 :type userid: Optional(str or int)
247 247
248 248 Example output:
249 249
250 250 .. code-block:: bash
251 251
252 252 "id": <id_given_in_input>,
253 253 "result": {
254 254 "executed": "<bool>",
255 255 "failure_reason": "<int>",
256 256 "merge_commit_id": "<merge_commit_id>",
257 257 "possible": "<bool>",
258 258 "merge_ref": {
259 259 "commit_id": "<commit_id>",
260 260 "type": "<type>",
261 261 "name": "<name>"
262 262 }
263 263 },
264 264 "error": null
265 265 """
266 266 repo = get_repo_or_error(repoid)
267 267 if not isinstance(userid, Optional):
268 268 if (has_superadmin_permission(apiuser) or
269 269 HasRepoPermissionAnyApi('repository.admin')(
270 270 user=apiuser, repo_name=repo.repo_name)):
271 271 apiuser = get_user_or_error(userid)
272 272 else:
273 273 raise JSONRPCError('userid is not the same as your user')
274 274
275 275 pull_request = get_pull_request_or_error(pullrequestid)
276 276
277 check = MergeCheck.validate(pull_request, user=apiuser)
277 check = MergeCheck.validate(
278 pull_request, user=apiuser, translator=request.translate)
278 279 merge_possible = not check.failed
279 280
280 281 if not merge_possible:
281 282 error_messages = []
282 283 for err_type, error_msg in check.errors:
283 284 error_msg = request.translate(error_msg)
284 285 error_messages.append(error_msg)
285 286
286 287 reasons = ','.join(error_messages)
287 288 raise JSONRPCError(
288 289 'merge not possible for following reasons: {}'.format(reasons))
289 290
290 291 target_repo = pull_request.target_repo
291 292 extras = vcs_operation_context(
292 293 request.environ, repo_name=target_repo.repo_name,
293 294 username=apiuser.username, action='push',
294 295 scm=target_repo.repo_type)
295 296 merge_response = PullRequestModel().merge(
296 297 pull_request, apiuser, extras=extras)
297 298 if merge_response.executed:
298 299 PullRequestModel().close_pull_request(
299 300 pull_request.pull_request_id, apiuser)
300 301
301 302 Session().commit()
302 303
303 304 # In previous versions the merge response directly contained the merge
304 305 # commit id. It is now contained in the merge reference object. To be
305 306 # backwards compatible we have to extract it again.
306 307 merge_response = merge_response._asdict()
307 308 merge_response['merge_commit_id'] = merge_response['merge_ref'].commit_id
308 309
309 310 return merge_response
310 311
311 312
312 313 @jsonrpc_method()
313 314 def comment_pull_request(
314 315 request, apiuser, repoid, pullrequestid, message=Optional(None),
315 316 commit_id=Optional(None), status=Optional(None),
316 317 comment_type=Optional(ChangesetComment.COMMENT_TYPE_NOTE),
317 318 resolves_comment_id=Optional(None),
318 319 userid=Optional(OAttr('apiuser'))):
319 320 """
320 321 Comment on the pull request specified with the `pullrequestid`,
321 322 in the |repo| specified by the `repoid`, and optionally change the
322 323 review status.
323 324
324 325 :param apiuser: This is filled automatically from the |authtoken|.
325 326 :type apiuser: AuthUser
326 327 :param repoid: The repository name or repository ID.
327 328 :type repoid: str or int
328 329 :param pullrequestid: The pull request ID.
329 330 :type pullrequestid: int
330 331 :param commit_id: Specify the commit_id for which to set a comment. If
331 332 given commit_id is different than latest in the PR status
332 333 change won't be performed.
333 334 :type commit_id: str
334 335 :param message: The text content of the comment.
335 336 :type message: str
336 337 :param status: (**Optional**) Set the approval status of the pull
337 338 request. One of: 'not_reviewed', 'approved', 'rejected',
338 339 'under_review'
339 340 :type status: str
340 341 :param comment_type: Comment type, one of: 'note', 'todo'
341 342 :type comment_type: Optional(str), default: 'note'
342 343 :param userid: Comment on the pull request as this user
343 344 :type userid: Optional(str or int)
344 345
345 346 Example output:
346 347
347 348 .. code-block:: bash
348 349
349 350 id : <id_given_in_input>
350 351 result : {
351 352 "pull_request_id": "<Integer>",
352 353 "comment_id": "<Integer>",
353 354 "status": {"given": <given_status>,
354 355 "was_changed": <bool status_was_actually_changed> },
355 356 },
356 357 error : null
357 358 """
358 359 repo = get_repo_or_error(repoid)
359 360 if not isinstance(userid, Optional):
360 361 if (has_superadmin_permission(apiuser) or
361 362 HasRepoPermissionAnyApi('repository.admin')(
362 363 user=apiuser, repo_name=repo.repo_name)):
363 364 apiuser = get_user_or_error(userid)
364 365 else:
365 366 raise JSONRPCError('userid is not the same as your user')
366 367
367 368 pull_request = get_pull_request_or_error(pullrequestid)
368 369 if not PullRequestModel().check_user_read(
369 370 pull_request, apiuser, api=True):
370 371 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
371 372 message = Optional.extract(message)
372 373 status = Optional.extract(status)
373 374 commit_id = Optional.extract(commit_id)
374 375 comment_type = Optional.extract(comment_type)
375 376 resolves_comment_id = Optional.extract(resolves_comment_id)
376 377
377 378 if not message and not status:
378 379 raise JSONRPCError(
379 380 'Both message and status parameters are missing. '
380 381 'At least one is required.')
381 382
382 383 if (status not in (st[0] for st in ChangesetStatus.STATUSES) and
383 384 status is not None):
384 385 raise JSONRPCError('Unknown comment status: `%s`' % status)
385 386
386 387 if commit_id and commit_id not in pull_request.revisions:
387 388 raise JSONRPCError(
388 389 'Invalid commit_id `%s` for this pull request.' % commit_id)
389 390
390 391 allowed_to_change_status = PullRequestModel().check_user_change_status(
391 392 pull_request, apiuser)
392 393
393 394 # if commit_id is passed re-validated if user is allowed to change status
394 395 # based on latest commit_id from the PR
395 396 if commit_id:
396 397 commit_idx = pull_request.revisions.index(commit_id)
397 398 if commit_idx != 0:
398 399 allowed_to_change_status = False
399 400
400 401 if resolves_comment_id:
401 402 comment = ChangesetComment.get(resolves_comment_id)
402 403 if not comment:
403 404 raise JSONRPCError(
404 405 'Invalid resolves_comment_id `%s` for this pull request.'
405 406 % resolves_comment_id)
406 407 if comment.comment_type != ChangesetComment.COMMENT_TYPE_TODO:
407 408 raise JSONRPCError(
408 409 'Comment `%s` is wrong type for setting status to resolved.'
409 410 % resolves_comment_id)
410 411
411 412 text = message
412 413 status_label = ChangesetStatus.get_status_lbl(status)
413 414 if status and allowed_to_change_status:
414 415 st_message = ('Status change %(transition_icon)s %(status)s'
415 416 % {'transition_icon': '>', 'status': status_label})
416 417 text = message or st_message
417 418
418 419 rc_config = SettingsModel().get_all_settings()
419 420 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
420 421
421 422 status_change = status and allowed_to_change_status
422 423 comment = CommentsModel().create(
423 424 text=text,
424 425 repo=pull_request.target_repo.repo_id,
425 426 user=apiuser.user_id,
426 427 pull_request=pull_request.pull_request_id,
427 428 f_path=None,
428 429 line_no=None,
429 430 status_change=(status_label if status_change else None),
430 431 status_change_type=(status if status_change else None),
431 432 closing_pr=False,
432 433 renderer=renderer,
433 434 comment_type=comment_type,
434 435 resolves_comment_id=resolves_comment_id
435 436 )
436 437
437 438 if allowed_to_change_status and status:
438 439 ChangesetStatusModel().set_status(
439 440 pull_request.target_repo.repo_id,
440 441 status,
441 442 apiuser.user_id,
442 443 comment,
443 444 pull_request=pull_request.pull_request_id
444 445 )
445 446 Session().flush()
446 447
447 448 Session().commit()
448 449 data = {
449 450 'pull_request_id': pull_request.pull_request_id,
450 451 'comment_id': comment.comment_id if comment else None,
451 452 'status': {'given': status, 'was_changed': status_change},
452 453 }
453 454 return data
454 455
455 456
456 457 @jsonrpc_method()
457 458 def create_pull_request(
458 459 request, apiuser, source_repo, target_repo, source_ref, target_ref,
459 460 title, description=Optional(''), reviewers=Optional(None)):
460 461 """
461 462 Creates a new pull request.
462 463
463 464 Accepts refs in the following formats:
464 465
465 466 * branch:<branch_name>:<sha>
466 467 * branch:<branch_name>
467 468 * bookmark:<bookmark_name>:<sha> (Mercurial only)
468 469 * bookmark:<bookmark_name> (Mercurial only)
469 470
470 471 :param apiuser: This is filled automatically from the |authtoken|.
471 472 :type apiuser: AuthUser
472 473 :param source_repo: Set the source repository name.
473 474 :type source_repo: str
474 475 :param target_repo: Set the target repository name.
475 476 :type target_repo: str
476 477 :param source_ref: Set the source ref name.
477 478 :type source_ref: str
478 479 :param target_ref: Set the target ref name.
479 480 :type target_ref: str
480 481 :param title: Set the pull request title.
481 482 :type title: str
482 483 :param description: Set the pull request description.
483 484 :type description: Optional(str)
484 485 :param reviewers: Set the new pull request reviewers list.
485 486 Reviewer defined by review rules will be added automatically to the
486 487 defined list.
487 488 :type reviewers: Optional(list)
488 489 Accepts username strings or objects of the format:
489 490
490 491 [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}]
491 492 """
492 493
493 494 source_db_repo = get_repo_or_error(source_repo)
494 495 target_db_repo = get_repo_or_error(target_repo)
495 496 if not has_superadmin_permission(apiuser):
496 497 _perms = ('repository.admin', 'repository.write', 'repository.read',)
497 498 validate_repo_permissions(apiuser, source_repo, source_db_repo, _perms)
498 499
499 500 full_source_ref = resolve_ref_or_error(source_ref, source_db_repo)
500 501 full_target_ref = resolve_ref_or_error(target_ref, target_db_repo)
501 502 source_commit = get_commit_or_error(full_source_ref, source_db_repo)
502 503 target_commit = get_commit_or_error(full_target_ref, target_db_repo)
503 504 source_scm = source_db_repo.scm_instance()
504 505 target_scm = target_db_repo.scm_instance()
505 506
506 507 commit_ranges = target_scm.compare(
507 508 target_commit.raw_id, source_commit.raw_id, source_scm,
508 509 merge=True, pre_load=[])
509 510
510 511 ancestor = target_scm.get_common_ancestor(
511 512 target_commit.raw_id, source_commit.raw_id, source_scm)
512 513
513 514 if not commit_ranges:
514 515 raise JSONRPCError('no commits found')
515 516
516 517 if not ancestor:
517 518 raise JSONRPCError('no common ancestor found')
518 519
519 520 reviewer_objects = Optional.extract(reviewers) or []
520 521
521 522 if reviewer_objects:
522 523 schema = ReviewerListSchema()
523 524 try:
524 525 reviewer_objects = schema.deserialize(reviewer_objects)
525 526 except Invalid as err:
526 527 raise JSONRPCValidationError(colander_exc=err)
527 528
528 529 # validate users
529 530 for reviewer_object in reviewer_objects:
530 531 user = get_user_or_error(reviewer_object['username'])
531 532 reviewer_object['user_id'] = user.user_id
532 533
533 534 get_default_reviewers_data, get_validated_reviewers = \
534 535 PullRequestModel().get_reviewer_functions()
535 536
536 537 reviewer_rules = get_default_reviewers_data(
537 538 apiuser.get_instance(), source_db_repo,
538 539 source_commit, target_db_repo, target_commit)
539 540
540 541 # specified rules are later re-validated, thus we can assume users will
541 542 # eventually provide those that meet the reviewer criteria.
542 543 if not reviewer_objects:
543 544 reviewer_objects = reviewer_rules['reviewers']
544 545
545 546 try:
546 547 reviewers = get_validated_reviewers(
547 548 reviewer_objects, reviewer_rules)
548 549 except ValueError as e:
549 550 raise JSONRPCError('Reviewers Validation: {}'.format(e))
550 551
551 552 pull_request_model = PullRequestModel()
552 553 pull_request = pull_request_model.create(
553 554 created_by=apiuser.user_id,
554 555 source_repo=source_repo,
555 556 source_ref=full_source_ref,
556 557 target_repo=target_repo,
557 558 target_ref=full_target_ref,
558 559 revisions=reversed(
559 560 [commit.raw_id for commit in reversed(commit_ranges)]),
560 561 reviewers=reviewers,
561 562 title=title,
562 563 description=Optional.extract(description)
563 564 )
564 565
565 566 Session().commit()
566 567 data = {
567 568 'msg': 'Created new pull request `{}`'.format(title),
568 569 'pull_request_id': pull_request.pull_request_id,
569 570 }
570 571 return data
571 572
572 573
573 574 @jsonrpc_method()
574 575 def update_pull_request(
575 576 request, apiuser, repoid, pullrequestid, title=Optional(''),
576 577 description=Optional(''), reviewers=Optional(None),
577 578 update_commits=Optional(None)):
578 579 """
579 580 Updates a pull request.
580 581
581 582 :param apiuser: This is filled automatically from the |authtoken|.
582 583 :type apiuser: AuthUser
583 584 :param repoid: The repository name or repository ID.
584 585 :type repoid: str or int
585 586 :param pullrequestid: The pull request ID.
586 587 :type pullrequestid: int
587 588 :param title: Set the pull request title.
588 589 :type title: str
589 590 :param description: Update pull request description.
590 591 :type description: Optional(str)
591 592 :param reviewers: Update pull request reviewers list with new value.
592 593 :type reviewers: Optional(list)
593 594 Accepts username strings or objects of the format:
594 595
595 596 [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}]
596 597
597 598 :param update_commits: Trigger update of commits for this pull request
598 599 :type: update_commits: Optional(bool)
599 600
600 601 Example output:
601 602
602 603 .. code-block:: bash
603 604
604 605 id : <id_given_in_input>
605 606 result : {
606 607 "msg": "Updated pull request `63`",
607 608 "pull_request": <pull_request_object>,
608 609 "updated_reviewers": {
609 610 "added": [
610 611 "username"
611 612 ],
612 613 "removed": []
613 614 },
614 615 "updated_commits": {
615 616 "added": [
616 617 "<sha1_hash>"
617 618 ],
618 619 "common": [
619 620 "<sha1_hash>",
620 621 "<sha1_hash>",
621 622 ],
622 623 "removed": []
623 624 }
624 625 }
625 626 error : null
626 627 """
627 628
628 629 repo = get_repo_or_error(repoid)
629 630 pull_request = get_pull_request_or_error(pullrequestid)
630 631 if not PullRequestModel().check_user_update(
631 632 pull_request, apiuser, api=True):
632 633 raise JSONRPCError(
633 634 'pull request `%s` update failed, no permission to update.' % (
634 635 pullrequestid,))
635 636 if pull_request.is_closed():
636 637 raise JSONRPCError(
637 638 'pull request `%s` update failed, pull request is closed' % (
638 639 pullrequestid,))
639 640
640 641 reviewer_objects = Optional.extract(reviewers) or []
641 642
642 643 if reviewer_objects:
643 644 schema = ReviewerListSchema()
644 645 try:
645 646 reviewer_objects = schema.deserialize(reviewer_objects)
646 647 except Invalid as err:
647 648 raise JSONRPCValidationError(colander_exc=err)
648 649
649 650 # validate users
650 651 for reviewer_object in reviewer_objects:
651 652 user = get_user_or_error(reviewer_object['username'])
652 653 reviewer_object['user_id'] = user.user_id
653 654
654 655 get_default_reviewers_data, get_validated_reviewers = \
655 656 PullRequestModel().get_reviewer_functions()
656 657
657 658 # re-use stored rules
658 659 reviewer_rules = pull_request.reviewer_data
659 660 try:
660 661 reviewers = get_validated_reviewers(
661 662 reviewer_objects, reviewer_rules)
662 663 except ValueError as e:
663 664 raise JSONRPCError('Reviewers Validation: {}'.format(e))
664 665 else:
665 666 reviewers = []
666 667
667 668 title = Optional.extract(title)
668 669 description = Optional.extract(description)
669 670 if title or description:
670 671 PullRequestModel().edit(
671 672 pull_request, title or pull_request.title,
672 673 description or pull_request.description, apiuser)
673 674 Session().commit()
674 675
675 676 commit_changes = {"added": [], "common": [], "removed": []}
676 677 if str2bool(Optional.extract(update_commits)):
677 678 if PullRequestModel().has_valid_update_type(pull_request):
678 679 update_response = PullRequestModel().update_commits(
679 680 pull_request)
680 681 commit_changes = update_response.changes or commit_changes
681 682 Session().commit()
682 683
683 684 reviewers_changes = {"added": [], "removed": []}
684 685 if reviewers:
685 686 added_reviewers, removed_reviewers = \
686 687 PullRequestModel().update_reviewers(pull_request, reviewers, apiuser)
687 688
688 689 reviewers_changes['added'] = sorted(
689 690 [get_user_or_error(n).username for n in added_reviewers])
690 691 reviewers_changes['removed'] = sorted(
691 692 [get_user_or_error(n).username for n in removed_reviewers])
692 693 Session().commit()
693 694
694 695 data = {
695 696 'msg': 'Updated pull request `{}`'.format(
696 697 pull_request.pull_request_id),
697 698 'pull_request': pull_request.get_api_data(),
698 699 'updated_commits': commit_changes,
699 700 'updated_reviewers': reviewers_changes
700 701 }
701 702
702 703 return data
703 704
704 705
705 706 @jsonrpc_method()
706 707 def close_pull_request(
707 708 request, apiuser, repoid, pullrequestid,
708 709 userid=Optional(OAttr('apiuser')), message=Optional('')):
709 710 """
710 711 Close the pull request specified by `pullrequestid`.
711 712
712 713 :param apiuser: This is filled automatically from the |authtoken|.
713 714 :type apiuser: AuthUser
714 715 :param repoid: Repository name or repository ID to which the pull
715 716 request belongs.
716 717 :type repoid: str or int
717 718 :param pullrequestid: ID of the pull request to be closed.
718 719 :type pullrequestid: int
719 720 :param userid: Close the pull request as this user.
720 721 :type userid: Optional(str or int)
721 722 :param message: Optional message to close the Pull Request with. If not
722 723 specified it will be generated automatically.
723 724 :type message: Optional(str)
724 725
725 726 Example output:
726 727
727 728 .. code-block:: bash
728 729
729 730 "id": <id_given_in_input>,
730 731 "result": {
731 732 "pull_request_id": "<int>",
732 733 "close_status": "<str:status_lbl>,
733 734 "closed": "<bool>"
734 735 },
735 736 "error": null
736 737
737 738 """
738 739 _ = request.translate
739 740
740 741 repo = get_repo_or_error(repoid)
741 742 if not isinstance(userid, Optional):
742 743 if (has_superadmin_permission(apiuser) or
743 744 HasRepoPermissionAnyApi('repository.admin')(
744 745 user=apiuser, repo_name=repo.repo_name)):
745 746 apiuser = get_user_or_error(userid)
746 747 else:
747 748 raise JSONRPCError('userid is not the same as your user')
748 749
749 750 pull_request = get_pull_request_or_error(pullrequestid)
750 751
751 752 if pull_request.is_closed():
752 753 raise JSONRPCError(
753 754 'pull request `%s` is already closed' % (pullrequestid,))
754 755
755 756 # only owner or admin or person with write permissions
756 757 allowed_to_close = PullRequestModel().check_user_update(
757 758 pull_request, apiuser, api=True)
758 759
759 760 if not allowed_to_close:
760 761 raise JSONRPCError(
761 762 'pull request `%s` close failed, no permission to close.' % (
762 763 pullrequestid,))
763 764
764 765 # message we're using to close the PR, else it's automatically generated
765 766 message = Optional.extract(message)
766 767
767 768 # finally close the PR, with proper message comment
768 769 comment, status = PullRequestModel().close_pull_request_with_comment(
769 770 pull_request, apiuser, repo, message=message)
770 771 status_lbl = ChangesetStatus.get_status_lbl(status)
771 772
772 773 Session().commit()
773 774
774 775 data = {
775 776 'pull_request_id': pull_request.pull_request_id,
776 777 'close_status': status_lbl,
777 778 'closed': True,
778 779 }
779 780 return data
@@ -1,1135 +1,1135 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20 import mock
21 21 import pytest
22 22
23 23 import rhodecode
24 24 from rhodecode.lib.vcs.backends.base import MergeResponse, MergeFailureReason
25 25 from rhodecode.lib.vcs.nodes import FileNode
26 26 from rhodecode.lib import helpers as h
27 27 from rhodecode.model.changeset_status import ChangesetStatusModel
28 28 from rhodecode.model.db import (
29 29 PullRequest, ChangesetStatus, UserLog, Notification, ChangesetComment)
30 30 from rhodecode.model.meta import Session
31 31 from rhodecode.model.pull_request import PullRequestModel
32 32 from rhodecode.model.user import UserModel
33 33 from rhodecode.tests import (
34 34 assert_session_flash, TEST_USER_ADMIN_LOGIN, TEST_USER_REGULAR_LOGIN)
35 35 from rhodecode.tests.utils import AssertResponse
36 36
37 37
38 38 def route_path(name, params=None, **kwargs):
39 39 import urllib
40 40
41 41 base_url = {
42 42 'repo_changelog': '/{repo_name}/changelog',
43 43 'repo_changelog_file': '/{repo_name}/changelog/{commit_id}/{f_path}',
44 44 'pullrequest_show': '/{repo_name}/pull-request/{pull_request_id}',
45 45 'pullrequest_show_all': '/{repo_name}/pull-request',
46 46 'pullrequest_show_all_data': '/{repo_name}/pull-request-data',
47 47 'pullrequest_repo_refs': '/{repo_name}/pull-request/refs/{target_repo_name:.*?[^/]}',
48 48 'pullrequest_repo_destinations': '/{repo_name}/pull-request/repo-destinations',
49 49 'pullrequest_new': '/{repo_name}/pull-request/new',
50 50 'pullrequest_create': '/{repo_name}/pull-request/create',
51 51 'pullrequest_update': '/{repo_name}/pull-request/{pull_request_id}/update',
52 52 'pullrequest_merge': '/{repo_name}/pull-request/{pull_request_id}/merge',
53 53 'pullrequest_delete': '/{repo_name}/pull-request/{pull_request_id}/delete',
54 54 'pullrequest_comment_create': '/{repo_name}/pull-request/{pull_request_id}/comment',
55 55 'pullrequest_comment_delete': '/{repo_name}/pull-request/{pull_request_id}/comment/{comment_id}/delete',
56 56 }[name].format(**kwargs)
57 57
58 58 if params:
59 59 base_url = '{}?{}'.format(base_url, urllib.urlencode(params))
60 60 return base_url
61 61
62 62
63 63 @pytest.mark.usefixtures('app', 'autologin_user')
64 64 @pytest.mark.backends("git", "hg")
65 65 class TestPullrequestsView(object):
66 66
67 67 def test_index(self, backend):
68 68 self.app.get(route_path(
69 69 'pullrequest_new',
70 70 repo_name=backend.repo_name))
71 71
72 72 def test_option_menu_create_pull_request_exists(self, backend):
73 73 repo_name = backend.repo_name
74 74 response = self.app.get(h.route_path('repo_summary', repo_name=repo_name))
75 75
76 76 create_pr_link = '<a href="%s">Create Pull Request</a>' % route_path(
77 77 'pullrequest_new', repo_name=repo_name)
78 78 response.mustcontain(create_pr_link)
79 79
80 80 def test_create_pr_form_with_raw_commit_id(self, backend):
81 81 repo = backend.repo
82 82
83 83 self.app.get(
84 84 route_path('pullrequest_new',
85 85 repo_name=repo.repo_name,
86 86 commit=repo.get_commit().raw_id),
87 87 status=200)
88 88
89 89 @pytest.mark.parametrize('pr_merge_enabled', [True, False])
90 90 def test_show(self, pr_util, pr_merge_enabled):
91 91 pull_request = pr_util.create_pull_request(
92 92 mergeable=pr_merge_enabled, enable_notifications=False)
93 93
94 94 response = self.app.get(route_path(
95 95 'pullrequest_show',
96 96 repo_name=pull_request.target_repo.scm_instance().name,
97 97 pull_request_id=pull_request.pull_request_id))
98 98
99 99 for commit_id in pull_request.revisions:
100 100 response.mustcontain(commit_id)
101 101
102 102 assert pull_request.target_ref_parts.type in response
103 103 assert pull_request.target_ref_parts.name in response
104 104 target_clone_url = pull_request.target_repo.clone_url()
105 105 assert target_clone_url in response
106 106
107 107 assert 'class="pull-request-merge"' in response
108 108 assert (
109 109 'Server-side pull request merging is disabled.'
110 110 in response) != pr_merge_enabled
111 111
112 112 def test_close_status_visibility(self, pr_util, user_util, csrf_token):
113 113 # Logout
114 114 response = self.app.post(
115 115 h.route_path('logout'),
116 116 params={'csrf_token': csrf_token})
117 117 # Login as regular user
118 118 response = self.app.post(h.route_path('login'),
119 119 {'username': TEST_USER_REGULAR_LOGIN,
120 120 'password': 'test12'})
121 121
122 122 pull_request = pr_util.create_pull_request(
123 123 author=TEST_USER_REGULAR_LOGIN)
124 124
125 125 response = self.app.get(route_path(
126 126 'pullrequest_show',
127 127 repo_name=pull_request.target_repo.scm_instance().name,
128 128 pull_request_id=pull_request.pull_request_id))
129 129
130 130 response.mustcontain('Server-side pull request merging is disabled.')
131 131
132 132 assert_response = response.assert_response()
133 133 # for regular user without a merge permissions, we don't see it
134 134 assert_response.no_element_exists('#close-pull-request-action')
135 135
136 136 user_util.grant_user_permission_to_repo(
137 137 pull_request.target_repo,
138 138 UserModel().get_by_username(TEST_USER_REGULAR_LOGIN),
139 139 'repository.write')
140 140 response = self.app.get(route_path(
141 141 'pullrequest_show',
142 142 repo_name=pull_request.target_repo.scm_instance().name,
143 143 pull_request_id=pull_request.pull_request_id))
144 144
145 145 response.mustcontain('Server-side pull request merging is disabled.')
146 146
147 147 assert_response = response.assert_response()
148 148 # now regular user has a merge permissions, we have CLOSE button
149 149 assert_response.one_element_exists('#close-pull-request-action')
150 150
151 151 def test_show_invalid_commit_id(self, pr_util):
152 152 # Simulating invalid revisions which will cause a lookup error
153 153 pull_request = pr_util.create_pull_request()
154 154 pull_request.revisions = ['invalid']
155 155 Session().add(pull_request)
156 156 Session().commit()
157 157
158 158 response = self.app.get(route_path(
159 159 'pullrequest_show',
160 160 repo_name=pull_request.target_repo.scm_instance().name,
161 161 pull_request_id=pull_request.pull_request_id))
162 162
163 163 for commit_id in pull_request.revisions:
164 164 response.mustcontain(commit_id)
165 165
166 166 def test_show_invalid_source_reference(self, pr_util):
167 167 pull_request = pr_util.create_pull_request()
168 168 pull_request.source_ref = 'branch:b:invalid'
169 169 Session().add(pull_request)
170 170 Session().commit()
171 171
172 172 self.app.get(route_path(
173 173 'pullrequest_show',
174 174 repo_name=pull_request.target_repo.scm_instance().name,
175 175 pull_request_id=pull_request.pull_request_id))
176 176
177 177 def test_edit_title_description(self, pr_util, csrf_token):
178 178 pull_request = pr_util.create_pull_request()
179 179 pull_request_id = pull_request.pull_request_id
180 180
181 181 response = self.app.post(
182 182 route_path('pullrequest_update',
183 183 repo_name=pull_request.target_repo.repo_name,
184 184 pull_request_id=pull_request_id),
185 185 params={
186 186 'edit_pull_request': 'true',
187 187 'title': 'New title',
188 188 'description': 'New description',
189 189 'csrf_token': csrf_token})
190 190
191 191 assert_session_flash(
192 192 response, u'Pull request title & description updated.',
193 193 category='success')
194 194
195 195 pull_request = PullRequest.get(pull_request_id)
196 196 assert pull_request.title == 'New title'
197 197 assert pull_request.description == 'New description'
198 198
199 199 def test_edit_title_description_closed(self, pr_util, csrf_token):
200 200 pull_request = pr_util.create_pull_request()
201 201 pull_request_id = pull_request.pull_request_id
202 202 pr_util.close()
203 203
204 204 response = self.app.post(
205 205 route_path('pullrequest_update',
206 206 repo_name=pull_request.target_repo.repo_name,
207 207 pull_request_id=pull_request_id),
208 208 params={
209 209 'edit_pull_request': 'true',
210 210 'title': 'New title',
211 211 'description': 'New description',
212 212 'csrf_token': csrf_token})
213 213
214 214 assert_session_flash(
215 215 response, u'Cannot update closed pull requests.',
216 216 category='error')
217 217
218 218 def test_update_invalid_source_reference(self, pr_util, csrf_token):
219 219 from rhodecode.lib.vcs.backends.base import UpdateFailureReason
220 220
221 221 pull_request = pr_util.create_pull_request()
222 222 pull_request.source_ref = 'branch:invalid-branch:invalid-commit-id'
223 223 Session().add(pull_request)
224 224 Session().commit()
225 225
226 226 pull_request_id = pull_request.pull_request_id
227 227
228 228 response = self.app.post(
229 229 route_path('pullrequest_update',
230 230 repo_name=pull_request.target_repo.repo_name,
231 231 pull_request_id=pull_request_id),
232 232 params={'update_commits': 'true',
233 233 'csrf_token': csrf_token})
234 234
235 expected_msg = PullRequestModel.UPDATE_STATUS_MESSAGES[
236 UpdateFailureReason.MISSING_SOURCE_REF]
235 expected_msg = str(PullRequestModel.UPDATE_STATUS_MESSAGES[
236 UpdateFailureReason.MISSING_SOURCE_REF])
237 237 assert_session_flash(response, expected_msg, category='error')
238 238
239 239 def test_missing_target_reference(self, pr_util, csrf_token):
240 240 from rhodecode.lib.vcs.backends.base import MergeFailureReason
241 241 pull_request = pr_util.create_pull_request(
242 242 approved=True, mergeable=True)
243 243 pull_request.target_ref = 'branch:invalid-branch:invalid-commit-id'
244 244 Session().add(pull_request)
245 245 Session().commit()
246 246
247 247 pull_request_id = pull_request.pull_request_id
248 248 pull_request_url = route_path(
249 249 'pullrequest_show',
250 250 repo_name=pull_request.target_repo.repo_name,
251 251 pull_request_id=pull_request_id)
252 252
253 253 response = self.app.get(pull_request_url)
254 254
255 255 assertr = AssertResponse(response)
256 256 expected_msg = PullRequestModel.MERGE_STATUS_MESSAGES[
257 257 MergeFailureReason.MISSING_TARGET_REF]
258 258 assertr.element_contains(
259 259 'span[data-role="merge-message"]', str(expected_msg))
260 260
261 261 def test_comment_and_close_pull_request_custom_message_approved(
262 262 self, pr_util, csrf_token, xhr_header):
263 263
264 264 pull_request = pr_util.create_pull_request(approved=True)
265 265 pull_request_id = pull_request.pull_request_id
266 266 author = pull_request.user_id
267 267 repo = pull_request.target_repo.repo_id
268 268
269 269 self.app.post(
270 270 route_path('pullrequest_comment_create',
271 271 repo_name=pull_request.target_repo.scm_instance().name,
272 272 pull_request_id=pull_request_id),
273 273 params={
274 274 'close_pull_request': '1',
275 275 'text': 'Closing a PR',
276 276 'csrf_token': csrf_token},
277 277 extra_environ=xhr_header,)
278 278
279 279 journal = UserLog.query()\
280 280 .filter(UserLog.user_id == author)\
281 281 .filter(UserLog.repository_id == repo) \
282 282 .order_by('user_log_id') \
283 283 .all()
284 284 assert journal[-1].action == 'repo.pull_request.close'
285 285
286 286 pull_request = PullRequest.get(pull_request_id)
287 287 assert pull_request.is_closed()
288 288
289 289 status = ChangesetStatusModel().get_status(
290 290 pull_request.source_repo, pull_request=pull_request)
291 291 assert status == ChangesetStatus.STATUS_APPROVED
292 292 comments = ChangesetComment().query() \
293 293 .filter(ChangesetComment.pull_request == pull_request) \
294 294 .order_by(ChangesetComment.comment_id.asc())\
295 295 .all()
296 296 assert comments[-1].text == 'Closing a PR'
297 297
298 298 def test_comment_force_close_pull_request_rejected(
299 299 self, pr_util, csrf_token, xhr_header):
300 300 pull_request = pr_util.create_pull_request()
301 301 pull_request_id = pull_request.pull_request_id
302 302 PullRequestModel().update_reviewers(
303 303 pull_request_id, [(1, ['reason'], False), (2, ['reason2'], False)],
304 304 pull_request.author)
305 305 author = pull_request.user_id
306 306 repo = pull_request.target_repo.repo_id
307 307
308 308 self.app.post(
309 309 route_path('pullrequest_comment_create',
310 310 repo_name=pull_request.target_repo.scm_instance().name,
311 311 pull_request_id=pull_request_id),
312 312 params={
313 313 'close_pull_request': '1',
314 314 'csrf_token': csrf_token},
315 315 extra_environ=xhr_header)
316 316
317 317 pull_request = PullRequest.get(pull_request_id)
318 318
319 319 journal = UserLog.query()\
320 320 .filter(UserLog.user_id == author, UserLog.repository_id == repo) \
321 321 .order_by('user_log_id') \
322 322 .all()
323 323 assert journal[-1].action == 'repo.pull_request.close'
324 324
325 325 # check only the latest status, not the review status
326 326 status = ChangesetStatusModel().get_status(
327 327 pull_request.source_repo, pull_request=pull_request)
328 328 assert status == ChangesetStatus.STATUS_REJECTED
329 329
330 330 def test_comment_and_close_pull_request(
331 331 self, pr_util, csrf_token, xhr_header):
332 332 pull_request = pr_util.create_pull_request()
333 333 pull_request_id = pull_request.pull_request_id
334 334
335 335 response = self.app.post(
336 336 route_path('pullrequest_comment_create',
337 337 repo_name=pull_request.target_repo.scm_instance().name,
338 338 pull_request_id=pull_request.pull_request_id),
339 339 params={
340 340 'close_pull_request': 'true',
341 341 'csrf_token': csrf_token},
342 342 extra_environ=xhr_header)
343 343
344 344 assert response.json
345 345
346 346 pull_request = PullRequest.get(pull_request_id)
347 347 assert pull_request.is_closed()
348 348
349 349 # check only the latest status, not the review status
350 350 status = ChangesetStatusModel().get_status(
351 351 pull_request.source_repo, pull_request=pull_request)
352 352 assert status == ChangesetStatus.STATUS_REJECTED
353 353
354 354 def test_create_pull_request(self, backend, csrf_token):
355 355 commits = [
356 356 {'message': 'ancestor'},
357 357 {'message': 'change'},
358 358 {'message': 'change2'},
359 359 ]
360 360 commit_ids = backend.create_master_repo(commits)
361 361 target = backend.create_repo(heads=['ancestor'])
362 362 source = backend.create_repo(heads=['change2'])
363 363
364 364 response = self.app.post(
365 365 route_path('pullrequest_create', repo_name=source.repo_name),
366 366 [
367 367 ('source_repo', source.repo_name),
368 368 ('source_ref', 'branch:default:' + commit_ids['change2']),
369 369 ('target_repo', target.repo_name),
370 370 ('target_ref', 'branch:default:' + commit_ids['ancestor']),
371 371 ('common_ancestor', commit_ids['ancestor']),
372 372 ('pullrequest_desc', 'Description'),
373 373 ('pullrequest_title', 'Title'),
374 374 ('__start__', 'review_members:sequence'),
375 375 ('__start__', 'reviewer:mapping'),
376 376 ('user_id', '1'),
377 377 ('__start__', 'reasons:sequence'),
378 378 ('reason', 'Some reason'),
379 379 ('__end__', 'reasons:sequence'),
380 380 ('mandatory', 'False'),
381 381 ('__end__', 'reviewer:mapping'),
382 382 ('__end__', 'review_members:sequence'),
383 383 ('__start__', 'revisions:sequence'),
384 384 ('revisions', commit_ids['change']),
385 385 ('revisions', commit_ids['change2']),
386 386 ('__end__', 'revisions:sequence'),
387 387 ('user', ''),
388 388 ('csrf_token', csrf_token),
389 389 ],
390 390 status=302)
391 391
392 392 location = response.headers['Location']
393 393 pull_request_id = location.rsplit('/', 1)[1]
394 394 assert pull_request_id != 'new'
395 395 pull_request = PullRequest.get(int(pull_request_id))
396 396
397 397 # check that we have now both revisions
398 398 assert pull_request.revisions == [commit_ids['change2'], commit_ids['change']]
399 399 assert pull_request.source_ref == 'branch:default:' + commit_ids['change2']
400 400 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
401 401 assert pull_request.target_ref == expected_target_ref
402 402
403 403 def test_reviewer_notifications(self, backend, csrf_token):
404 404 # We have to use the app.post for this test so it will create the
405 405 # notifications properly with the new PR
406 406 commits = [
407 407 {'message': 'ancestor',
408 408 'added': [FileNode('file_A', content='content_of_ancestor')]},
409 409 {'message': 'change',
410 410 'added': [FileNode('file_a', content='content_of_change')]},
411 411 {'message': 'change-child'},
412 412 {'message': 'ancestor-child', 'parents': ['ancestor'],
413 413 'added': [
414 414 FileNode('file_B', content='content_of_ancestor_child')]},
415 415 {'message': 'ancestor-child-2'},
416 416 ]
417 417 commit_ids = backend.create_master_repo(commits)
418 418 target = backend.create_repo(heads=['ancestor-child'])
419 419 source = backend.create_repo(heads=['change'])
420 420
421 421 response = self.app.post(
422 422 route_path('pullrequest_create', repo_name=source.repo_name),
423 423 [
424 424 ('source_repo', source.repo_name),
425 425 ('source_ref', 'branch:default:' + commit_ids['change']),
426 426 ('target_repo', target.repo_name),
427 427 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
428 428 ('common_ancestor', commit_ids['ancestor']),
429 429 ('pullrequest_desc', 'Description'),
430 430 ('pullrequest_title', 'Title'),
431 431 ('__start__', 'review_members:sequence'),
432 432 ('__start__', 'reviewer:mapping'),
433 433 ('user_id', '2'),
434 434 ('__start__', 'reasons:sequence'),
435 435 ('reason', 'Some reason'),
436 436 ('__end__', 'reasons:sequence'),
437 437 ('mandatory', 'False'),
438 438 ('__end__', 'reviewer:mapping'),
439 439 ('__end__', 'review_members:sequence'),
440 440 ('__start__', 'revisions:sequence'),
441 441 ('revisions', commit_ids['change']),
442 442 ('__end__', 'revisions:sequence'),
443 443 ('user', ''),
444 444 ('csrf_token', csrf_token),
445 445 ],
446 446 status=302)
447 447
448 448 location = response.headers['Location']
449 449
450 450 pull_request_id = location.rsplit('/', 1)[1]
451 451 assert pull_request_id != 'new'
452 452 pull_request = PullRequest.get(int(pull_request_id))
453 453
454 454 # Check that a notification was made
455 455 notifications = Notification.query()\
456 456 .filter(Notification.created_by == pull_request.author.user_id,
457 457 Notification.type_ == Notification.TYPE_PULL_REQUEST,
458 458 Notification.subject.contains(
459 459 "wants you to review pull request #%s" % pull_request_id))
460 460 assert len(notifications.all()) == 1
461 461
462 462 # Change reviewers and check that a notification was made
463 463 PullRequestModel().update_reviewers(
464 464 pull_request.pull_request_id, [(1, [], False)],
465 465 pull_request.author)
466 466 assert len(notifications.all()) == 2
467 467
468 468 def test_create_pull_request_stores_ancestor_commit_id(self, backend,
469 469 csrf_token):
470 470 commits = [
471 471 {'message': 'ancestor',
472 472 'added': [FileNode('file_A', content='content_of_ancestor')]},
473 473 {'message': 'change',
474 474 'added': [FileNode('file_a', content='content_of_change')]},
475 475 {'message': 'change-child'},
476 476 {'message': 'ancestor-child', 'parents': ['ancestor'],
477 477 'added': [
478 478 FileNode('file_B', content='content_of_ancestor_child')]},
479 479 {'message': 'ancestor-child-2'},
480 480 ]
481 481 commit_ids = backend.create_master_repo(commits)
482 482 target = backend.create_repo(heads=['ancestor-child'])
483 483 source = backend.create_repo(heads=['change'])
484 484
485 485 response = self.app.post(
486 486 route_path('pullrequest_create', repo_name=source.repo_name),
487 487 [
488 488 ('source_repo', source.repo_name),
489 489 ('source_ref', 'branch:default:' + commit_ids['change']),
490 490 ('target_repo', target.repo_name),
491 491 ('target_ref', 'branch:default:' + commit_ids['ancestor-child']),
492 492 ('common_ancestor', commit_ids['ancestor']),
493 493 ('pullrequest_desc', 'Description'),
494 494 ('pullrequest_title', 'Title'),
495 495 ('__start__', 'review_members:sequence'),
496 496 ('__start__', 'reviewer:mapping'),
497 497 ('user_id', '1'),
498 498 ('__start__', 'reasons:sequence'),
499 499 ('reason', 'Some reason'),
500 500 ('__end__', 'reasons:sequence'),
501 501 ('mandatory', 'False'),
502 502 ('__end__', 'reviewer:mapping'),
503 503 ('__end__', 'review_members:sequence'),
504 504 ('__start__', 'revisions:sequence'),
505 505 ('revisions', commit_ids['change']),
506 506 ('__end__', 'revisions:sequence'),
507 507 ('user', ''),
508 508 ('csrf_token', csrf_token),
509 509 ],
510 510 status=302)
511 511
512 512 location = response.headers['Location']
513 513
514 514 pull_request_id = location.rsplit('/', 1)[1]
515 515 assert pull_request_id != 'new'
516 516 pull_request = PullRequest.get(int(pull_request_id))
517 517
518 518 # target_ref has to point to the ancestor's commit_id in order to
519 519 # show the correct diff
520 520 expected_target_ref = 'branch:default:' + commit_ids['ancestor']
521 521 assert pull_request.target_ref == expected_target_ref
522 522
523 523 # Check generated diff contents
524 524 response = response.follow()
525 525 assert 'content_of_ancestor' not in response.body
526 526 assert 'content_of_ancestor-child' not in response.body
527 527 assert 'content_of_change' in response.body
528 528
529 529 def test_merge_pull_request_enabled(self, pr_util, csrf_token):
530 530 # Clear any previous calls to rcextensions
531 531 rhodecode.EXTENSIONS.calls.clear()
532 532
533 533 pull_request = pr_util.create_pull_request(
534 534 approved=True, mergeable=True)
535 535 pull_request_id = pull_request.pull_request_id
536 536 repo_name = pull_request.target_repo.scm_instance().name,
537 537
538 538 response = self.app.post(
539 539 route_path('pullrequest_merge',
540 540 repo_name=str(repo_name[0]),
541 541 pull_request_id=pull_request_id),
542 542 params={'csrf_token': csrf_token}).follow()
543 543
544 544 pull_request = PullRequest.get(pull_request_id)
545 545
546 546 assert response.status_int == 200
547 547 assert pull_request.is_closed()
548 548 assert_pull_request_status(
549 549 pull_request, ChangesetStatus.STATUS_APPROVED)
550 550
551 551 # Check the relevant log entries were added
552 552 user_logs = UserLog.query().order_by('-user_log_id').limit(3)
553 553 actions = [log.action for log in user_logs]
554 554 pr_commit_ids = PullRequestModel()._get_commit_ids(pull_request)
555 555 expected_actions = [
556 556 u'repo.pull_request.close',
557 557 u'repo.pull_request.merge',
558 558 u'repo.pull_request.comment.create'
559 559 ]
560 560 assert actions == expected_actions
561 561
562 562 user_logs = UserLog.query().order_by('-user_log_id').limit(4)
563 563 actions = [log for log in user_logs]
564 564 assert actions[-1].action == 'user.push'
565 565 assert actions[-1].action_data['commit_ids'] == pr_commit_ids
566 566
567 567 # Check post_push rcextension was really executed
568 568 push_calls = rhodecode.EXTENSIONS.calls['post_push']
569 569 assert len(push_calls) == 1
570 570 unused_last_call_args, last_call_kwargs = push_calls[0]
571 571 assert last_call_kwargs['action'] == 'push'
572 572 assert last_call_kwargs['pushed_revs'] == pr_commit_ids
573 573
574 574 def test_merge_pull_request_disabled(self, pr_util, csrf_token):
575 575 pull_request = pr_util.create_pull_request(mergeable=False)
576 576 pull_request_id = pull_request.pull_request_id
577 577 pull_request = PullRequest.get(pull_request_id)
578 578
579 579 response = self.app.post(
580 580 route_path('pullrequest_merge',
581 581 repo_name=pull_request.target_repo.scm_instance().name,
582 582 pull_request_id=pull_request.pull_request_id),
583 583 params={'csrf_token': csrf_token}).follow()
584 584
585 585 assert response.status_int == 200
586 586 response.mustcontain(
587 587 'Merge is not currently possible because of below failed checks.')
588 588 response.mustcontain('Server-side pull request merging is disabled.')
589 589
590 590 @pytest.mark.skip_backends('svn')
591 591 def test_merge_pull_request_not_approved(self, pr_util, csrf_token):
592 592 pull_request = pr_util.create_pull_request(mergeable=True)
593 593 pull_request_id = pull_request.pull_request_id
594 594 repo_name = pull_request.target_repo.scm_instance().name
595 595
596 596 response = self.app.post(
597 597 route_path('pullrequest_merge',
598 598 repo_name=repo_name,
599 599 pull_request_id=pull_request_id),
600 600 params={'csrf_token': csrf_token}).follow()
601 601
602 602 assert response.status_int == 200
603 603
604 604 response.mustcontain(
605 605 'Merge is not currently possible because of below failed checks.')
606 606 response.mustcontain('Pull request reviewer approval is pending.')
607 607
608 608 def test_merge_pull_request_renders_failure_reason(
609 609 self, user_regular, csrf_token, pr_util):
610 610 pull_request = pr_util.create_pull_request(mergeable=True, approved=True)
611 611 pull_request_id = pull_request.pull_request_id
612 612 repo_name = pull_request.target_repo.scm_instance().name
613 613
614 614 model_patcher = mock.patch.multiple(
615 615 PullRequestModel,
616 616 merge=mock.Mock(return_value=MergeResponse(
617 617 True, False, 'STUB_COMMIT_ID', MergeFailureReason.PUSH_FAILED)),
618 618 merge_status=mock.Mock(return_value=(True, 'WRONG_MESSAGE')))
619 619
620 620 with model_patcher:
621 621 response = self.app.post(
622 622 route_path('pullrequest_merge',
623 623 repo_name=repo_name,
624 624 pull_request_id=pull_request_id),
625 625 params={'csrf_token': csrf_token}, status=302)
626 626
627 627 assert_session_flash(response, PullRequestModel.MERGE_STATUS_MESSAGES[
628 628 MergeFailureReason.PUSH_FAILED])
629 629
630 630 def test_update_source_revision(self, backend, csrf_token):
631 631 commits = [
632 632 {'message': 'ancestor'},
633 633 {'message': 'change'},
634 634 {'message': 'change-2'},
635 635 ]
636 636 commit_ids = backend.create_master_repo(commits)
637 637 target = backend.create_repo(heads=['ancestor'])
638 638 source = backend.create_repo(heads=['change'])
639 639
640 640 # create pr from a in source to A in target
641 641 pull_request = PullRequest()
642 642 pull_request.source_repo = source
643 643 # TODO: johbo: Make sure that we write the source ref this way!
644 644 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
645 645 branch=backend.default_branch_name, commit_id=commit_ids['change'])
646 646 pull_request.target_repo = target
647 647
648 648 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
649 649 branch=backend.default_branch_name,
650 650 commit_id=commit_ids['ancestor'])
651 651 pull_request.revisions = [commit_ids['change']]
652 652 pull_request.title = u"Test"
653 653 pull_request.description = u"Description"
654 654 pull_request.author = UserModel().get_by_username(
655 655 TEST_USER_ADMIN_LOGIN)
656 656 Session().add(pull_request)
657 657 Session().commit()
658 658 pull_request_id = pull_request.pull_request_id
659 659
660 660 # source has ancestor - change - change-2
661 661 backend.pull_heads(source, heads=['change-2'])
662 662
663 663 # update PR
664 664 self.app.post(
665 665 route_path('pullrequest_update',
666 666 repo_name=target.repo_name,
667 667 pull_request_id=pull_request_id),
668 668 params={'update_commits': 'true',
669 669 'csrf_token': csrf_token})
670 670
671 671 # check that we have now both revisions
672 672 pull_request = PullRequest.get(pull_request_id)
673 673 assert pull_request.revisions == [
674 674 commit_ids['change-2'], commit_ids['change']]
675 675
676 676 # TODO: johbo: this should be a test on its own
677 677 response = self.app.get(route_path(
678 678 'pullrequest_new',
679 679 repo_name=target.repo_name))
680 680 assert response.status_int == 200
681 681 assert 'Pull request updated to' in response.body
682 682 assert 'with 1 added, 0 removed commits.' in response.body
683 683
684 684 def test_update_target_revision(self, backend, csrf_token):
685 685 commits = [
686 686 {'message': 'ancestor'},
687 687 {'message': 'change'},
688 688 {'message': 'ancestor-new', 'parents': ['ancestor']},
689 689 {'message': 'change-rebased'},
690 690 ]
691 691 commit_ids = backend.create_master_repo(commits)
692 692 target = backend.create_repo(heads=['ancestor'])
693 693 source = backend.create_repo(heads=['change'])
694 694
695 695 # create pr from a in source to A in target
696 696 pull_request = PullRequest()
697 697 pull_request.source_repo = source
698 698 # TODO: johbo: Make sure that we write the source ref this way!
699 699 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
700 700 branch=backend.default_branch_name, commit_id=commit_ids['change'])
701 701 pull_request.target_repo = target
702 702 # TODO: johbo: Target ref should be branch based, since tip can jump
703 703 # from branch to branch
704 704 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
705 705 branch=backend.default_branch_name,
706 706 commit_id=commit_ids['ancestor'])
707 707 pull_request.revisions = [commit_ids['change']]
708 708 pull_request.title = u"Test"
709 709 pull_request.description = u"Description"
710 710 pull_request.author = UserModel().get_by_username(
711 711 TEST_USER_ADMIN_LOGIN)
712 712 Session().add(pull_request)
713 713 Session().commit()
714 714 pull_request_id = pull_request.pull_request_id
715 715
716 716 # target has ancestor - ancestor-new
717 717 # source has ancestor - ancestor-new - change-rebased
718 718 backend.pull_heads(target, heads=['ancestor-new'])
719 719 backend.pull_heads(source, heads=['change-rebased'])
720 720
721 721 # update PR
722 722 self.app.post(
723 723 route_path('pullrequest_update',
724 724 repo_name=target.repo_name,
725 725 pull_request_id=pull_request_id),
726 726 params={'update_commits': 'true',
727 727 'csrf_token': csrf_token},
728 728 status=200)
729 729
730 730 # check that we have now both revisions
731 731 pull_request = PullRequest.get(pull_request_id)
732 732 assert pull_request.revisions == [commit_ids['change-rebased']]
733 733 assert pull_request.target_ref == 'branch:{branch}:{commit_id}'.format(
734 734 branch=backend.default_branch_name,
735 735 commit_id=commit_ids['ancestor-new'])
736 736
737 737 # TODO: johbo: This should be a test on its own
738 738 response = self.app.get(route_path(
739 739 'pullrequest_new',
740 740 repo_name=target.repo_name))
741 741 assert response.status_int == 200
742 742 assert 'Pull request updated to' in response.body
743 743 assert 'with 1 added, 1 removed commits.' in response.body
744 744
745 745 def test_update_of_ancestor_reference(self, backend, csrf_token):
746 746 commits = [
747 747 {'message': 'ancestor'},
748 748 {'message': 'change'},
749 749 {'message': 'change-2'},
750 750 {'message': 'ancestor-new', 'parents': ['ancestor']},
751 751 {'message': 'change-rebased'},
752 752 ]
753 753 commit_ids = backend.create_master_repo(commits)
754 754 target = backend.create_repo(heads=['ancestor'])
755 755 source = backend.create_repo(heads=['change'])
756 756
757 757 # create pr from a in source to A in target
758 758 pull_request = PullRequest()
759 759 pull_request.source_repo = source
760 760 # TODO: johbo: Make sure that we write the source ref this way!
761 761 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
762 762 branch=backend.default_branch_name,
763 763 commit_id=commit_ids['change'])
764 764 pull_request.target_repo = target
765 765 # TODO: johbo: Target ref should be branch based, since tip can jump
766 766 # from branch to branch
767 767 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
768 768 branch=backend.default_branch_name,
769 769 commit_id=commit_ids['ancestor'])
770 770 pull_request.revisions = [commit_ids['change']]
771 771 pull_request.title = u"Test"
772 772 pull_request.description = u"Description"
773 773 pull_request.author = UserModel().get_by_username(
774 774 TEST_USER_ADMIN_LOGIN)
775 775 Session().add(pull_request)
776 776 Session().commit()
777 777 pull_request_id = pull_request.pull_request_id
778 778
779 779 # target has ancestor - ancestor-new
780 780 # source has ancestor - ancestor-new - change-rebased
781 781 backend.pull_heads(target, heads=['ancestor-new'])
782 782 backend.pull_heads(source, heads=['change-rebased'])
783 783
784 784 # update PR
785 785 self.app.post(
786 786 route_path('pullrequest_update',
787 787 repo_name=target.repo_name,
788 788 pull_request_id=pull_request_id),
789 789 params={'update_commits': 'true',
790 790 'csrf_token': csrf_token},
791 791 status=200)
792 792
793 793 # Expect the target reference to be updated correctly
794 794 pull_request = PullRequest.get(pull_request_id)
795 795 assert pull_request.revisions == [commit_ids['change-rebased']]
796 796 expected_target_ref = 'branch:{branch}:{commit_id}'.format(
797 797 branch=backend.default_branch_name,
798 798 commit_id=commit_ids['ancestor-new'])
799 799 assert pull_request.target_ref == expected_target_ref
800 800
801 801 def test_remove_pull_request_branch(self, backend_git, csrf_token):
802 802 branch_name = 'development'
803 803 commits = [
804 804 {'message': 'initial-commit'},
805 805 {'message': 'old-feature'},
806 806 {'message': 'new-feature', 'branch': branch_name},
807 807 ]
808 808 repo = backend_git.create_repo(commits)
809 809 commit_ids = backend_git.commit_ids
810 810
811 811 pull_request = PullRequest()
812 812 pull_request.source_repo = repo
813 813 pull_request.target_repo = repo
814 814 pull_request.source_ref = 'branch:{branch}:{commit_id}'.format(
815 815 branch=branch_name, commit_id=commit_ids['new-feature'])
816 816 pull_request.target_ref = 'branch:{branch}:{commit_id}'.format(
817 817 branch=backend_git.default_branch_name,
818 818 commit_id=commit_ids['old-feature'])
819 819 pull_request.revisions = [commit_ids['new-feature']]
820 820 pull_request.title = u"Test"
821 821 pull_request.description = u"Description"
822 822 pull_request.author = UserModel().get_by_username(
823 823 TEST_USER_ADMIN_LOGIN)
824 824 Session().add(pull_request)
825 825 Session().commit()
826 826
827 827 vcs = repo.scm_instance()
828 828 vcs.remove_ref('refs/heads/{}'.format(branch_name))
829 829
830 830 response = self.app.get(route_path(
831 831 'pullrequest_show',
832 832 repo_name=repo.repo_name,
833 833 pull_request_id=pull_request.pull_request_id))
834 834
835 835 assert response.status_int == 200
836 836 assert_response = AssertResponse(response)
837 837 assert_response.element_contains(
838 838 '#changeset_compare_view_content .alert strong',
839 839 'Missing commits')
840 840 assert_response.element_contains(
841 841 '#changeset_compare_view_content .alert',
842 842 'This pull request cannot be displayed, because one or more'
843 843 ' commits no longer exist in the source repository.')
844 844
845 845 def test_strip_commits_from_pull_request(
846 846 self, backend, pr_util, csrf_token):
847 847 commits = [
848 848 {'message': 'initial-commit'},
849 849 {'message': 'old-feature'},
850 850 {'message': 'new-feature', 'parents': ['initial-commit']},
851 851 ]
852 852 pull_request = pr_util.create_pull_request(
853 853 commits, target_head='initial-commit', source_head='new-feature',
854 854 revisions=['new-feature'])
855 855
856 856 vcs = pr_util.source_repository.scm_instance()
857 857 if backend.alias == 'git':
858 858 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
859 859 else:
860 860 vcs.strip(pr_util.commit_ids['new-feature'])
861 861
862 862 response = self.app.get(route_path(
863 863 'pullrequest_show',
864 864 repo_name=pr_util.target_repository.repo_name,
865 865 pull_request_id=pull_request.pull_request_id))
866 866
867 867 assert response.status_int == 200
868 868 assert_response = AssertResponse(response)
869 869 assert_response.element_contains(
870 870 '#changeset_compare_view_content .alert strong',
871 871 'Missing commits')
872 872 assert_response.element_contains(
873 873 '#changeset_compare_view_content .alert',
874 874 'This pull request cannot be displayed, because one or more'
875 875 ' commits no longer exist in the source repository.')
876 876 assert_response.element_contains(
877 877 '#update_commits',
878 878 'Update commits')
879 879
880 880 def test_strip_commits_and_update(
881 881 self, backend, pr_util, csrf_token):
882 882 commits = [
883 883 {'message': 'initial-commit'},
884 884 {'message': 'old-feature'},
885 885 {'message': 'new-feature', 'parents': ['old-feature']},
886 886 ]
887 887 pull_request = pr_util.create_pull_request(
888 888 commits, target_head='old-feature', source_head='new-feature',
889 889 revisions=['new-feature'], mergeable=True)
890 890
891 891 vcs = pr_util.source_repository.scm_instance()
892 892 if backend.alias == 'git':
893 893 vcs.strip(pr_util.commit_ids['new-feature'], branch_name='master')
894 894 else:
895 895 vcs.strip(pr_util.commit_ids['new-feature'])
896 896
897 897 response = self.app.post(
898 898 route_path('pullrequest_update',
899 899 repo_name=pull_request.target_repo.repo_name,
900 900 pull_request_id=pull_request.pull_request_id),
901 901 params={'update_commits': 'true',
902 902 'csrf_token': csrf_token})
903 903
904 904 assert response.status_int == 200
905 905 assert response.body == 'true'
906 906
907 907 # Make sure that after update, it won't raise 500 errors
908 908 response = self.app.get(route_path(
909 909 'pullrequest_show',
910 910 repo_name=pr_util.target_repository.repo_name,
911 911 pull_request_id=pull_request.pull_request_id))
912 912
913 913 assert response.status_int == 200
914 914 assert_response = AssertResponse(response)
915 915 assert_response.element_contains(
916 916 '#changeset_compare_view_content .alert strong',
917 917 'Missing commits')
918 918
919 919 def test_branch_is_a_link(self, pr_util):
920 920 pull_request = pr_util.create_pull_request()
921 921 pull_request.source_ref = 'branch:origin:1234567890abcdef'
922 922 pull_request.target_ref = 'branch:target:abcdef1234567890'
923 923 Session().add(pull_request)
924 924 Session().commit()
925 925
926 926 response = self.app.get(route_path(
927 927 'pullrequest_show',
928 928 repo_name=pull_request.target_repo.scm_instance().name,
929 929 pull_request_id=pull_request.pull_request_id))
930 930 assert response.status_int == 200
931 931 assert_response = AssertResponse(response)
932 932
933 933 origin = assert_response.get_element('.pr-origininfo .tag')
934 934 origin_children = origin.getchildren()
935 935 assert len(origin_children) == 1
936 936 target = assert_response.get_element('.pr-targetinfo .tag')
937 937 target_children = target.getchildren()
938 938 assert len(target_children) == 1
939 939
940 940 expected_origin_link = route_path(
941 941 'repo_changelog',
942 942 repo_name=pull_request.source_repo.scm_instance().name,
943 943 params=dict(branch='origin'))
944 944 expected_target_link = route_path(
945 945 'repo_changelog',
946 946 repo_name=pull_request.target_repo.scm_instance().name,
947 947 params=dict(branch='target'))
948 948 assert origin_children[0].attrib['href'] == expected_origin_link
949 949 assert origin_children[0].text == 'branch: origin'
950 950 assert target_children[0].attrib['href'] == expected_target_link
951 951 assert target_children[0].text == 'branch: target'
952 952
953 953 def test_bookmark_is_not_a_link(self, pr_util):
954 954 pull_request = pr_util.create_pull_request()
955 955 pull_request.source_ref = 'bookmark:origin:1234567890abcdef'
956 956 pull_request.target_ref = 'bookmark:target:abcdef1234567890'
957 957 Session().add(pull_request)
958 958 Session().commit()
959 959
960 960 response = self.app.get(route_path(
961 961 'pullrequest_show',
962 962 repo_name=pull_request.target_repo.scm_instance().name,
963 963 pull_request_id=pull_request.pull_request_id))
964 964 assert response.status_int == 200
965 965 assert_response = AssertResponse(response)
966 966
967 967 origin = assert_response.get_element('.pr-origininfo .tag')
968 968 assert origin.text.strip() == 'bookmark: origin'
969 969 assert origin.getchildren() == []
970 970
971 971 target = assert_response.get_element('.pr-targetinfo .tag')
972 972 assert target.text.strip() == 'bookmark: target'
973 973 assert target.getchildren() == []
974 974
975 975 def test_tag_is_not_a_link(self, pr_util):
976 976 pull_request = pr_util.create_pull_request()
977 977 pull_request.source_ref = 'tag:origin:1234567890abcdef'
978 978 pull_request.target_ref = 'tag:target:abcdef1234567890'
979 979 Session().add(pull_request)
980 980 Session().commit()
981 981
982 982 response = self.app.get(route_path(
983 983 'pullrequest_show',
984 984 repo_name=pull_request.target_repo.scm_instance().name,
985 985 pull_request_id=pull_request.pull_request_id))
986 986 assert response.status_int == 200
987 987 assert_response = AssertResponse(response)
988 988
989 989 origin = assert_response.get_element('.pr-origininfo .tag')
990 990 assert origin.text.strip() == 'tag: origin'
991 991 assert origin.getchildren() == []
992 992
993 993 target = assert_response.get_element('.pr-targetinfo .tag')
994 994 assert target.text.strip() == 'tag: target'
995 995 assert target.getchildren() == []
996 996
997 997 @pytest.mark.parametrize('mergeable', [True, False])
998 998 def test_shadow_repository_link(
999 999 self, mergeable, pr_util, http_host_only_stub):
1000 1000 """
1001 1001 Check that the pull request summary page displays a link to the shadow
1002 1002 repository if the pull request is mergeable. If it is not mergeable
1003 1003 the link should not be displayed.
1004 1004 """
1005 1005 pull_request = pr_util.create_pull_request(
1006 1006 mergeable=mergeable, enable_notifications=False)
1007 1007 target_repo = pull_request.target_repo.scm_instance()
1008 1008 pr_id = pull_request.pull_request_id
1009 1009 shadow_url = '{host}/{repo}/pull-request/{pr_id}/repository'.format(
1010 1010 host=http_host_only_stub, repo=target_repo.name, pr_id=pr_id)
1011 1011
1012 1012 response = self.app.get(route_path(
1013 1013 'pullrequest_show',
1014 1014 repo_name=target_repo.name,
1015 1015 pull_request_id=pr_id))
1016 1016
1017 1017 assertr = AssertResponse(response)
1018 1018 if mergeable:
1019 1019 assertr.element_value_contains('input.pr-mergeinfo', shadow_url)
1020 1020 assertr.element_value_contains('input.pr-mergeinfo ', 'pr-merge')
1021 1021 else:
1022 1022 assertr.no_element_exists('.pr-mergeinfo')
1023 1023
1024 1024
1025 1025 @pytest.mark.usefixtures('app')
1026 1026 @pytest.mark.backends("git", "hg")
1027 1027 class TestPullrequestsControllerDelete(object):
1028 1028 def test_pull_request_delete_button_permissions_admin(
1029 1029 self, autologin_user, user_admin, pr_util):
1030 1030 pull_request = pr_util.create_pull_request(
1031 1031 author=user_admin.username, enable_notifications=False)
1032 1032
1033 1033 response = self.app.get(route_path(
1034 1034 'pullrequest_show',
1035 1035 repo_name=pull_request.target_repo.scm_instance().name,
1036 1036 pull_request_id=pull_request.pull_request_id))
1037 1037
1038 1038 response.mustcontain('id="delete_pullrequest"')
1039 1039 response.mustcontain('Confirm to delete this pull request')
1040 1040
1041 1041 def test_pull_request_delete_button_permissions_owner(
1042 1042 self, autologin_regular_user, user_regular, pr_util):
1043 1043 pull_request = pr_util.create_pull_request(
1044 1044 author=user_regular.username, enable_notifications=False)
1045 1045
1046 1046 response = self.app.get(route_path(
1047 1047 'pullrequest_show',
1048 1048 repo_name=pull_request.target_repo.scm_instance().name,
1049 1049 pull_request_id=pull_request.pull_request_id))
1050 1050
1051 1051 response.mustcontain('id="delete_pullrequest"')
1052 1052 response.mustcontain('Confirm to delete this pull request')
1053 1053
1054 1054 def test_pull_request_delete_button_permissions_forbidden(
1055 1055 self, autologin_regular_user, user_regular, user_admin, pr_util):
1056 1056 pull_request = pr_util.create_pull_request(
1057 1057 author=user_admin.username, enable_notifications=False)
1058 1058
1059 1059 response = self.app.get(route_path(
1060 1060 'pullrequest_show',
1061 1061 repo_name=pull_request.target_repo.scm_instance().name,
1062 1062 pull_request_id=pull_request.pull_request_id))
1063 1063 response.mustcontain(no=['id="delete_pullrequest"'])
1064 1064 response.mustcontain(no=['Confirm to delete this pull request'])
1065 1065
1066 1066 def test_pull_request_delete_button_permissions_can_update_cannot_delete(
1067 1067 self, autologin_regular_user, user_regular, user_admin, pr_util,
1068 1068 user_util):
1069 1069
1070 1070 pull_request = pr_util.create_pull_request(
1071 1071 author=user_admin.username, enable_notifications=False)
1072 1072
1073 1073 user_util.grant_user_permission_to_repo(
1074 1074 pull_request.target_repo, user_regular,
1075 1075 'repository.write')
1076 1076
1077 1077 response = self.app.get(route_path(
1078 1078 'pullrequest_show',
1079 1079 repo_name=pull_request.target_repo.scm_instance().name,
1080 1080 pull_request_id=pull_request.pull_request_id))
1081 1081
1082 1082 response.mustcontain('id="open_edit_pullrequest"')
1083 1083 response.mustcontain('id="delete_pullrequest"')
1084 1084 response.mustcontain(no=['Confirm to delete this pull request'])
1085 1085
1086 1086 def test_delete_comment_returns_404_if_comment_does_not_exist(
1087 1087 self, autologin_user, pr_util, user_admin, csrf_token, xhr_header):
1088 1088
1089 1089 pull_request = pr_util.create_pull_request(
1090 1090 author=user_admin.username, enable_notifications=False)
1091 1091
1092 1092 self.app.post(
1093 1093 route_path(
1094 1094 'pullrequest_comment_delete',
1095 1095 repo_name=pull_request.target_repo.scm_instance().name,
1096 1096 pull_request_id=pull_request.pull_request_id,
1097 1097 comment_id=1024404),
1098 1098 extra_environ=xhr_header,
1099 1099 params={'csrf_token': csrf_token},
1100 1100 status=404
1101 1101 )
1102 1102
1103 1103 def test_delete_comment(
1104 1104 self, autologin_user, pr_util, user_admin, csrf_token, xhr_header):
1105 1105
1106 1106 pull_request = pr_util.create_pull_request(
1107 1107 author=user_admin.username, enable_notifications=False)
1108 1108 comment = pr_util.create_comment()
1109 1109 comment_id = comment.comment_id
1110 1110
1111 1111 response = self.app.post(
1112 1112 route_path(
1113 1113 'pullrequest_comment_delete',
1114 1114 repo_name=pull_request.target_repo.scm_instance().name,
1115 1115 pull_request_id=pull_request.pull_request_id,
1116 1116 comment_id=comment_id),
1117 1117 extra_environ=xhr_header,
1118 1118 params={'csrf_token': csrf_token},
1119 1119 status=200
1120 1120 )
1121 1121 assert response.body == 'true'
1122 1122
1123 1123
1124 1124 def assert_pull_request_status(pull_request, expected_status):
1125 1125 status = ChangesetStatusModel().calculated_review_status(
1126 1126 pull_request=pull_request)
1127 1127 assert status == expected_status
1128 1128
1129 1129
1130 1130 @pytest.mark.parametrize('route', ['pullrequest_new', 'pullrequest_create'])
1131 1131 @pytest.mark.usefixtures("autologin_user")
1132 1132 def test_forbidde_to_repo_summary_for_svn_repositories(backend_svn, app, route):
1133 1133 response = app.get(
1134 1134 route_path(route, repo_name=backend_svn.repo_name), status=404)
1135 1135
@@ -1,1192 +1,1196 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import collections
23 23
24 24 import formencode
25 25 import formencode.htmlfill
26 26 import peppercorn
27 27 from pyramid.httpexceptions import (
28 28 HTTPFound, HTTPNotFound, HTTPForbidden, HTTPBadRequest)
29 29 from pyramid.view import view_config
30 30 from pyramid.renderers import render
31 31
32 32 from rhodecode import events
33 33 from rhodecode.apps._base import RepoAppView, DataGridAppView
34 34
35 35 from rhodecode.lib import helpers as h, diffs, codeblocks, channelstream
36 36 from rhodecode.lib.base import vcs_operation_context
37 37 from rhodecode.lib.ext_json import json
38 38 from rhodecode.lib.auth import (
39 39 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous, CSRFRequired)
40 40 from rhodecode.lib.utils2 import str2bool, safe_str, safe_unicode
41 41 from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason
42 42 from rhodecode.lib.vcs.exceptions import (CommitDoesNotExistError,
43 43 RepositoryRequirementError, NodeDoesNotExistError, EmptyRepositoryError)
44 44 from rhodecode.model.changeset_status import ChangesetStatusModel
45 45 from rhodecode.model.comment import CommentsModel
46 46 from rhodecode.model.db import (func, or_, PullRequest, PullRequestVersion,
47 47 ChangesetComment, ChangesetStatus, Repository)
48 48 from rhodecode.model.forms import PullRequestForm
49 49 from rhodecode.model.meta import Session
50 50 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
51 51 from rhodecode.model.scm import ScmModel
52 52
53 53 log = logging.getLogger(__name__)
54 54
55 55
56 56 class RepoPullRequestsView(RepoAppView, DataGridAppView):
57 57
58 58 def load_default_context(self):
59 59 c = self._get_local_tmpl_context(include_app_defaults=True)
60 60 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
61 61 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
62 62 self._register_global_c(c)
63 63 return c
64 64
65 65 def _get_pull_requests_list(
66 66 self, repo_name, source, filter_type, opened_by, statuses):
67 67
68 68 draw, start, limit = self._extract_chunk(self.request)
69 69 search_q, order_by, order_dir = self._extract_ordering(self.request)
70 70 _render = self.request.get_partial_renderer(
71 71 'data_table/_dt_elements.mako')
72 72
73 73 # pagination
74 74
75 75 if filter_type == 'awaiting_review':
76 76 pull_requests = PullRequestModel().get_awaiting_review(
77 77 repo_name, source=source, opened_by=opened_by,
78 78 statuses=statuses, offset=start, length=limit,
79 79 order_by=order_by, order_dir=order_dir)
80 80 pull_requests_total_count = PullRequestModel().count_awaiting_review(
81 81 repo_name, source=source, statuses=statuses,
82 82 opened_by=opened_by)
83 83 elif filter_type == 'awaiting_my_review':
84 84 pull_requests = PullRequestModel().get_awaiting_my_review(
85 85 repo_name, source=source, opened_by=opened_by,
86 86 user_id=self._rhodecode_user.user_id, statuses=statuses,
87 87 offset=start, length=limit, order_by=order_by,
88 88 order_dir=order_dir)
89 89 pull_requests_total_count = PullRequestModel().count_awaiting_my_review(
90 90 repo_name, source=source, user_id=self._rhodecode_user.user_id,
91 91 statuses=statuses, opened_by=opened_by)
92 92 else:
93 93 pull_requests = PullRequestModel().get_all(
94 94 repo_name, source=source, opened_by=opened_by,
95 95 statuses=statuses, offset=start, length=limit,
96 96 order_by=order_by, order_dir=order_dir)
97 97 pull_requests_total_count = PullRequestModel().count_all(
98 98 repo_name, source=source, statuses=statuses,
99 99 opened_by=opened_by)
100 100
101 101 data = []
102 102 comments_model = CommentsModel()
103 103 for pr in pull_requests:
104 104 comments = comments_model.get_all_comments(
105 105 self.db_repo.repo_id, pull_request=pr)
106 106
107 107 data.append({
108 108 'name': _render('pullrequest_name',
109 109 pr.pull_request_id, pr.target_repo.repo_name),
110 110 'name_raw': pr.pull_request_id,
111 111 'status': _render('pullrequest_status',
112 112 pr.calculated_review_status()),
113 113 'title': _render(
114 114 'pullrequest_title', pr.title, pr.description),
115 115 'description': h.escape(pr.description),
116 116 'updated_on': _render('pullrequest_updated_on',
117 117 h.datetime_to_time(pr.updated_on)),
118 118 'updated_on_raw': h.datetime_to_time(pr.updated_on),
119 119 'created_on': _render('pullrequest_updated_on',
120 120 h.datetime_to_time(pr.created_on)),
121 121 'created_on_raw': h.datetime_to_time(pr.created_on),
122 122 'author': _render('pullrequest_author',
123 123 pr.author.full_contact, ),
124 124 'author_raw': pr.author.full_name,
125 125 'comments': _render('pullrequest_comments', len(comments)),
126 126 'comments_raw': len(comments),
127 127 'closed': pr.is_closed(),
128 128 })
129 129
130 130 data = ({
131 131 'draw': draw,
132 132 'data': data,
133 133 'recordsTotal': pull_requests_total_count,
134 134 'recordsFiltered': pull_requests_total_count,
135 135 })
136 136 return data
137 137
138 138 @LoginRequired()
139 139 @HasRepoPermissionAnyDecorator(
140 140 'repository.read', 'repository.write', 'repository.admin')
141 141 @view_config(
142 142 route_name='pullrequest_show_all', request_method='GET',
143 143 renderer='rhodecode:templates/pullrequests/pullrequests.mako')
144 144 def pull_request_list(self):
145 145 c = self.load_default_context()
146 146
147 147 req_get = self.request.GET
148 148 c.source = str2bool(req_get.get('source'))
149 149 c.closed = str2bool(req_get.get('closed'))
150 150 c.my = str2bool(req_get.get('my'))
151 151 c.awaiting_review = str2bool(req_get.get('awaiting_review'))
152 152 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
153 153
154 154 c.active = 'open'
155 155 if c.my:
156 156 c.active = 'my'
157 157 if c.closed:
158 158 c.active = 'closed'
159 159 if c.awaiting_review and not c.source:
160 160 c.active = 'awaiting'
161 161 if c.source and not c.awaiting_review:
162 162 c.active = 'source'
163 163 if c.awaiting_my_review:
164 164 c.active = 'awaiting_my'
165 165
166 166 return self._get_template_context(c)
167 167
168 168 @LoginRequired()
169 169 @HasRepoPermissionAnyDecorator(
170 170 'repository.read', 'repository.write', 'repository.admin')
171 171 @view_config(
172 172 route_name='pullrequest_show_all_data', request_method='GET',
173 173 renderer='json_ext', xhr=True)
174 174 def pull_request_list_data(self):
175 175
176 176 # additional filters
177 177 req_get = self.request.GET
178 178 source = str2bool(req_get.get('source'))
179 179 closed = str2bool(req_get.get('closed'))
180 180 my = str2bool(req_get.get('my'))
181 181 awaiting_review = str2bool(req_get.get('awaiting_review'))
182 182 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
183 183
184 184 filter_type = 'awaiting_review' if awaiting_review \
185 185 else 'awaiting_my_review' if awaiting_my_review \
186 186 else None
187 187
188 188 opened_by = None
189 189 if my:
190 190 opened_by = [self._rhodecode_user.user_id]
191 191
192 192 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
193 193 if closed:
194 194 statuses = [PullRequest.STATUS_CLOSED]
195 195
196 196 data = self._get_pull_requests_list(
197 197 repo_name=self.db_repo_name, source=source,
198 198 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
199 199
200 200 return data
201 201
202 202 def _get_pr_version(self, pull_request_id, version=None):
203 203 at_version = None
204 204
205 205 if version and version == 'latest':
206 206 pull_request_ver = PullRequest.get(pull_request_id)
207 207 pull_request_obj = pull_request_ver
208 208 _org_pull_request_obj = pull_request_obj
209 209 at_version = 'latest'
210 210 elif version:
211 211 pull_request_ver = PullRequestVersion.get_or_404(version)
212 212 pull_request_obj = pull_request_ver
213 213 _org_pull_request_obj = pull_request_ver.pull_request
214 214 at_version = pull_request_ver.pull_request_version_id
215 215 else:
216 216 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
217 217 pull_request_id)
218 218
219 219 pull_request_display_obj = PullRequest.get_pr_display_object(
220 220 pull_request_obj, _org_pull_request_obj)
221 221
222 222 return _org_pull_request_obj, pull_request_obj, \
223 223 pull_request_display_obj, at_version
224 224
225 225 def _get_diffset(self, source_repo_name, source_repo,
226 226 source_ref_id, target_ref_id,
227 227 target_commit, source_commit, diff_limit, fulldiff,
228 228 file_limit, display_inline_comments):
229 229
230 230 vcs_diff = PullRequestModel().get_diff(
231 231 source_repo, source_ref_id, target_ref_id)
232 232
233 233 diff_processor = diffs.DiffProcessor(
234 234 vcs_diff, format='newdiff', diff_limit=diff_limit,
235 235 file_limit=file_limit, show_full_diff=fulldiff)
236 236
237 237 _parsed = diff_processor.prepare()
238 238
239 239 def _node_getter(commit):
240 240 def get_node(fname):
241 241 try:
242 242 return commit.get_node(fname)
243 243 except NodeDoesNotExistError:
244 244 return None
245 245
246 246 return get_node
247 247
248 248 diffset = codeblocks.DiffSet(
249 249 repo_name=self.db_repo_name,
250 250 source_repo_name=source_repo_name,
251 251 source_node_getter=_node_getter(target_commit),
252 252 target_node_getter=_node_getter(source_commit),
253 253 comments=display_inline_comments
254 254 )
255 255 diffset = diffset.render_patchset(
256 256 _parsed, target_commit.raw_id, source_commit.raw_id)
257 257
258 258 return diffset
259 259
260 260 @LoginRequired()
261 261 @HasRepoPermissionAnyDecorator(
262 262 'repository.read', 'repository.write', 'repository.admin')
263 263 @view_config(
264 264 route_name='pullrequest_show', request_method='GET',
265 265 renderer='rhodecode:templates/pullrequests/pullrequest_show.mako')
266 266 def pull_request_show(self):
267 267 pull_request_id = self.request.matchdict['pull_request_id']
268 268
269 269 c = self.load_default_context()
270 270
271 271 version = self.request.GET.get('version')
272 272 from_version = self.request.GET.get('from_version') or version
273 273 merge_checks = self.request.GET.get('merge_checks')
274 274 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
275 275
276 276 (pull_request_latest,
277 277 pull_request_at_ver,
278 278 pull_request_display_obj,
279 279 at_version) = self._get_pr_version(
280 280 pull_request_id, version=version)
281 281 pr_closed = pull_request_latest.is_closed()
282 282
283 283 if pr_closed and (version or from_version):
284 284 # not allow to browse versions
285 285 raise HTTPFound(h.route_path(
286 286 'pullrequest_show', repo_name=self.db_repo_name,
287 287 pull_request_id=pull_request_id))
288 288
289 289 versions = pull_request_display_obj.versions()
290 290
291 291 c.at_version = at_version
292 292 c.at_version_num = (at_version
293 293 if at_version and at_version != 'latest'
294 294 else None)
295 295 c.at_version_pos = ChangesetComment.get_index_from_version(
296 296 c.at_version_num, versions)
297 297
298 298 (prev_pull_request_latest,
299 299 prev_pull_request_at_ver,
300 300 prev_pull_request_display_obj,
301 301 prev_at_version) = self._get_pr_version(
302 302 pull_request_id, version=from_version)
303 303
304 304 c.from_version = prev_at_version
305 305 c.from_version_num = (prev_at_version
306 306 if prev_at_version and prev_at_version != 'latest'
307 307 else None)
308 308 c.from_version_pos = ChangesetComment.get_index_from_version(
309 309 c.from_version_num, versions)
310 310
311 311 # define if we're in COMPARE mode or VIEW at version mode
312 312 compare = at_version != prev_at_version
313 313
314 314 # pull_requests repo_name we opened it against
315 315 # ie. target_repo must match
316 316 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
317 317 raise HTTPNotFound()
318 318
319 319 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
320 320 pull_request_at_ver)
321 321
322 322 c.pull_request = pull_request_display_obj
323 323 c.pull_request_latest = pull_request_latest
324 324
325 325 if compare or (at_version and not at_version == 'latest'):
326 326 c.allowed_to_change_status = False
327 327 c.allowed_to_update = False
328 328 c.allowed_to_merge = False
329 329 c.allowed_to_delete = False
330 330 c.allowed_to_comment = False
331 331 c.allowed_to_close = False
332 332 else:
333 333 can_change_status = PullRequestModel().check_user_change_status(
334 334 pull_request_at_ver, self._rhodecode_user)
335 335 c.allowed_to_change_status = can_change_status and not pr_closed
336 336
337 337 c.allowed_to_update = PullRequestModel().check_user_update(
338 338 pull_request_latest, self._rhodecode_user) and not pr_closed
339 339 c.allowed_to_merge = PullRequestModel().check_user_merge(
340 340 pull_request_latest, self._rhodecode_user) and not pr_closed
341 341 c.allowed_to_delete = PullRequestModel().check_user_delete(
342 342 pull_request_latest, self._rhodecode_user) and not pr_closed
343 343 c.allowed_to_comment = not pr_closed
344 344 c.allowed_to_close = c.allowed_to_merge and not pr_closed
345 345
346 346 c.forbid_adding_reviewers = False
347 347 c.forbid_author_to_review = False
348 348 c.forbid_commit_author_to_review = False
349 349
350 350 if pull_request_latest.reviewer_data and \
351 351 'rules' in pull_request_latest.reviewer_data:
352 352 rules = pull_request_latest.reviewer_data['rules'] or {}
353 353 try:
354 354 c.forbid_adding_reviewers = rules.get(
355 355 'forbid_adding_reviewers')
356 356 c.forbid_author_to_review = rules.get(
357 357 'forbid_author_to_review')
358 358 c.forbid_commit_author_to_review = rules.get(
359 359 'forbid_commit_author_to_review')
360 360 except Exception:
361 361 pass
362 362
363 363 # check merge capabilities
364 364 _merge_check = MergeCheck.validate(
365 pull_request_latest, user=self._rhodecode_user)
365 pull_request_latest, user=self._rhodecode_user,
366 translator=self.request.translate)
366 367 c.pr_merge_errors = _merge_check.error_details
367 368 c.pr_merge_possible = not _merge_check.failed
368 369 c.pr_merge_message = _merge_check.merge_msg
369 370
370 c.pr_merge_info = MergeCheck.get_merge_conditions(pull_request_latest)
371 c.pr_merge_info = MergeCheck.get_merge_conditions(
372 pull_request_latest, translator=self.request.translate)
371 373
372 374 c.pull_request_review_status = _merge_check.review_status
373 375 if merge_checks:
374 376 self.request.override_renderer = \
375 377 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
376 378 return self._get_template_context(c)
377 379
378 380 comments_model = CommentsModel()
379 381
380 382 # reviewers and statuses
381 383 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
382 384 allowed_reviewers = [x[0].user_id for x in c.pull_request_reviewers]
383 385
384 386 # GENERAL COMMENTS with versions #
385 387 q = comments_model._all_general_comments_of_pull_request(pull_request_latest)
386 388 q = q.order_by(ChangesetComment.comment_id.asc())
387 389 general_comments = q
388 390
389 391 # pick comments we want to render at current version
390 392 c.comment_versions = comments_model.aggregate_comments(
391 393 general_comments, versions, c.at_version_num)
392 394 c.comments = c.comment_versions[c.at_version_num]['until']
393 395
394 396 # INLINE COMMENTS with versions #
395 397 q = comments_model._all_inline_comments_of_pull_request(pull_request_latest)
396 398 q = q.order_by(ChangesetComment.comment_id.asc())
397 399 inline_comments = q
398 400
399 401 c.inline_versions = comments_model.aggregate_comments(
400 402 inline_comments, versions, c.at_version_num, inline=True)
401 403
402 404 # inject latest version
403 405 latest_ver = PullRequest.get_pr_display_object(
404 406 pull_request_latest, pull_request_latest)
405 407
406 408 c.versions = versions + [latest_ver]
407 409
408 410 # if we use version, then do not show later comments
409 411 # than current version
410 412 display_inline_comments = collections.defaultdict(
411 413 lambda: collections.defaultdict(list))
412 414 for co in inline_comments:
413 415 if c.at_version_num:
414 416 # pick comments that are at least UPTO given version, so we
415 417 # don't render comments for higher version
416 418 should_render = co.pull_request_version_id and \
417 419 co.pull_request_version_id <= c.at_version_num
418 420 else:
419 421 # showing all, for 'latest'
420 422 should_render = True
421 423
422 424 if should_render:
423 425 display_inline_comments[co.f_path][co.line_no].append(co)
424 426
425 427 # load diff data into template context, if we use compare mode then
426 428 # diff is calculated based on changes between versions of PR
427 429
428 430 source_repo = pull_request_at_ver.source_repo
429 431 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
430 432
431 433 target_repo = pull_request_at_ver.target_repo
432 434 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
433 435
434 436 if compare:
435 437 # in compare switch the diff base to latest commit from prev version
436 438 target_ref_id = prev_pull_request_display_obj.revisions[0]
437 439
438 440 # despite opening commits for bookmarks/branches/tags, we always
439 441 # convert this to rev to prevent changes after bookmark or branch change
440 442 c.source_ref_type = 'rev'
441 443 c.source_ref = source_ref_id
442 444
443 445 c.target_ref_type = 'rev'
444 446 c.target_ref = target_ref_id
445 447
446 448 c.source_repo = source_repo
447 449 c.target_repo = target_repo
448 450
449 451 c.commit_ranges = []
450 452 source_commit = EmptyCommit()
451 453 target_commit = EmptyCommit()
452 454 c.missing_requirements = False
453 455
454 456 source_scm = source_repo.scm_instance()
455 457 target_scm = target_repo.scm_instance()
456 458
457 459 # try first shadow repo, fallback to regular repo
458 460 try:
459 461 commits_source_repo = pull_request_latest.get_shadow_repo()
460 462 except Exception:
461 463 log.debug('Failed to get shadow repo', exc_info=True)
462 464 commits_source_repo = source_scm
463 465
464 466 c.commits_source_repo = commits_source_repo
465 467 commit_cache = {}
466 468 try:
467 469 pre_load = ["author", "branch", "date", "message"]
468 470 show_revs = pull_request_at_ver.revisions
469 471 for rev in show_revs:
470 472 comm = commits_source_repo.get_commit(
471 473 commit_id=rev, pre_load=pre_load)
472 474 c.commit_ranges.append(comm)
473 475 commit_cache[comm.raw_id] = comm
474 476
475 477 # Order here matters, we first need to get target, and then
476 478 # the source
477 479 target_commit = commits_source_repo.get_commit(
478 480 commit_id=safe_str(target_ref_id))
479 481
480 482 source_commit = commits_source_repo.get_commit(
481 483 commit_id=safe_str(source_ref_id))
482 484
483 485 except CommitDoesNotExistError:
484 486 log.warning(
485 487 'Failed to get commit from `{}` repo'.format(
486 488 commits_source_repo), exc_info=True)
487 489 except RepositoryRequirementError:
488 490 log.warning(
489 491 'Failed to get all required data from repo', exc_info=True)
490 492 c.missing_requirements = True
491 493
492 494 c.ancestor = None # set it to None, to hide it from PR view
493 495
494 496 try:
495 497 ancestor_id = source_scm.get_common_ancestor(
496 498 source_commit.raw_id, target_commit.raw_id, target_scm)
497 499 c.ancestor_commit = source_scm.get_commit(ancestor_id)
498 500 except Exception:
499 501 c.ancestor_commit = None
500 502
501 503 c.statuses = source_repo.statuses(
502 504 [x.raw_id for x in c.commit_ranges])
503 505
504 506 # auto collapse if we have more than limit
505 507 collapse_limit = diffs.DiffProcessor._collapse_commits_over
506 508 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
507 509 c.compare_mode = compare
508 510
509 511 # diff_limit is the old behavior, will cut off the whole diff
510 512 # if the limit is applied otherwise will just hide the
511 513 # big files from the front-end
512 514 diff_limit = c.visual.cut_off_limit_diff
513 515 file_limit = c.visual.cut_off_limit_file
514 516
515 517 c.missing_commits = False
516 518 if (c.missing_requirements
517 519 or isinstance(source_commit, EmptyCommit)
518 520 or source_commit == target_commit):
519 521
520 522 c.missing_commits = True
521 523 else:
522 524
523 525 c.diffset = self._get_diffset(
524 526 c.source_repo.repo_name, commits_source_repo,
525 527 source_ref_id, target_ref_id,
526 528 target_commit, source_commit,
527 529 diff_limit, c.fulldiff, file_limit, display_inline_comments)
528 530
529 531 c.limited_diff = c.diffset.limited_diff
530 532
531 533 # calculate removed files that are bound to comments
532 534 comment_deleted_files = [
533 535 fname for fname in display_inline_comments
534 536 if fname not in c.diffset.file_stats]
535 537
536 538 c.deleted_files_comments = collections.defaultdict(dict)
537 539 for fname, per_line_comments in display_inline_comments.items():
538 540 if fname in comment_deleted_files:
539 541 c.deleted_files_comments[fname]['stats'] = 0
540 542 c.deleted_files_comments[fname]['comments'] = list()
541 543 for lno, comments in per_line_comments.items():
542 544 c.deleted_files_comments[fname]['comments'].extend(
543 545 comments)
544 546
545 547 # this is a hack to properly display links, when creating PR, the
546 548 # compare view and others uses different notation, and
547 549 # compare_commits.mako renders links based on the target_repo.
548 550 # We need to swap that here to generate it properly on the html side
549 551 c.target_repo = c.source_repo
550 552
551 553 c.commit_statuses = ChangesetStatus.STATUSES
552 554
553 555 c.show_version_changes = not pr_closed
554 556 if c.show_version_changes:
555 557 cur_obj = pull_request_at_ver
556 558 prev_obj = prev_pull_request_at_ver
557 559
558 560 old_commit_ids = prev_obj.revisions
559 561 new_commit_ids = cur_obj.revisions
560 562 commit_changes = PullRequestModel()._calculate_commit_id_changes(
561 563 old_commit_ids, new_commit_ids)
562 564 c.commit_changes_summary = commit_changes
563 565
564 566 # calculate the diff for commits between versions
565 567 c.commit_changes = []
566 568 mark = lambda cs, fw: list(
567 569 h.itertools.izip_longest([], cs, fillvalue=fw))
568 570 for c_type, raw_id in mark(commit_changes.added, 'a') \
569 571 + mark(commit_changes.removed, 'r') \
570 572 + mark(commit_changes.common, 'c'):
571 573
572 574 if raw_id in commit_cache:
573 575 commit = commit_cache[raw_id]
574 576 else:
575 577 try:
576 578 commit = commits_source_repo.get_commit(raw_id)
577 579 except CommitDoesNotExistError:
578 580 # in case we fail extracting still use "dummy" commit
579 581 # for display in commit diff
580 582 commit = h.AttributeDict(
581 583 {'raw_id': raw_id,
582 584 'message': 'EMPTY or MISSING COMMIT'})
583 585 c.commit_changes.append([c_type, commit])
584 586
585 587 # current user review statuses for each version
586 588 c.review_versions = {}
587 589 if self._rhodecode_user.user_id in allowed_reviewers:
588 590 for co in general_comments:
589 591 if co.author.user_id == self._rhodecode_user.user_id:
590 592 # each comment has a status change
591 593 status = co.status_change
592 594 if status:
593 595 _ver_pr = status[0].comment.pull_request_version_id
594 596 c.review_versions[_ver_pr] = status[0]
595 597
596 598 return self._get_template_context(c)
597 599
598 600 def assure_not_empty_repo(self):
599 601 _ = self.request.translate
600 602
601 603 try:
602 604 self.db_repo.scm_instance().get_commit()
603 605 except EmptyRepositoryError:
604 606 h.flash(h.literal(_('There are no commits yet')),
605 607 category='warning')
606 608 raise HTTPFound(
607 609 h.route_path('repo_summary', repo_name=self.db_repo.repo_name))
608 610
609 611 @LoginRequired()
610 612 @NotAnonymous()
611 613 @HasRepoPermissionAnyDecorator(
612 614 'repository.read', 'repository.write', 'repository.admin')
613 615 @view_config(
614 616 route_name='pullrequest_new', request_method='GET',
615 617 renderer='rhodecode:templates/pullrequests/pullrequest.mako')
616 618 def pull_request_new(self):
617 619 _ = self.request.translate
618 620 c = self.load_default_context()
619 621
620 622 self.assure_not_empty_repo()
621 623 source_repo = self.db_repo
622 624
623 625 commit_id = self.request.GET.get('commit')
624 626 branch_ref = self.request.GET.get('branch')
625 627 bookmark_ref = self.request.GET.get('bookmark')
626 628
627 629 try:
628 630 source_repo_data = PullRequestModel().generate_repo_data(
629 631 source_repo, commit_id=commit_id,
630 branch=branch_ref, bookmark=bookmark_ref)
632 branch=branch_ref, bookmark=bookmark_ref, translator=self.request.translate)
631 633 except CommitDoesNotExistError as e:
632 634 log.exception(e)
633 635 h.flash(_('Commit does not exist'), 'error')
634 636 raise HTTPFound(
635 637 h.route_path('pullrequest_new', repo_name=source_repo.repo_name))
636 638
637 639 default_target_repo = source_repo
638 640
639 641 if source_repo.parent:
640 642 parent_vcs_obj = source_repo.parent.scm_instance()
641 643 if parent_vcs_obj and not parent_vcs_obj.is_empty():
642 644 # change default if we have a parent repo
643 645 default_target_repo = source_repo.parent
644 646
645 647 target_repo_data = PullRequestModel().generate_repo_data(
646 default_target_repo)
648 default_target_repo, translator=self.request.translate)
647 649
648 650 selected_source_ref = source_repo_data['refs']['selected_ref']
649 651
650 652 title_source_ref = selected_source_ref.split(':', 2)[1]
651 653 c.default_title = PullRequestModel().generate_pullrequest_title(
652 654 source=source_repo.repo_name,
653 655 source_ref=title_source_ref,
654 656 target=default_target_repo.repo_name
655 657 )
656 658
657 659 c.default_repo_data = {
658 660 'source_repo_name': source_repo.repo_name,
659 661 'source_refs_json': json.dumps(source_repo_data),
660 662 'target_repo_name': default_target_repo.repo_name,
661 663 'target_refs_json': json.dumps(target_repo_data),
662 664 }
663 665 c.default_source_ref = selected_source_ref
664 666
665 667 return self._get_template_context(c)
666 668
667 669 @LoginRequired()
668 670 @NotAnonymous()
669 671 @HasRepoPermissionAnyDecorator(
670 672 'repository.read', 'repository.write', 'repository.admin')
671 673 @view_config(
672 674 route_name='pullrequest_repo_refs', request_method='GET',
673 675 renderer='json_ext', xhr=True)
674 676 def pull_request_repo_refs(self):
675 677 target_repo_name = self.request.matchdict['target_repo_name']
676 678 repo = Repository.get_by_repo_name(target_repo_name)
677 679 if not repo:
678 680 raise HTTPNotFound()
679 return PullRequestModel().generate_repo_data(repo)
681 return PullRequestModel().generate_repo_data(repo, translator=self.request.translate)
680 682
681 683 @LoginRequired()
682 684 @NotAnonymous()
683 685 @HasRepoPermissionAnyDecorator(
684 686 'repository.read', 'repository.write', 'repository.admin')
685 687 @view_config(
686 688 route_name='pullrequest_repo_destinations', request_method='GET',
687 689 renderer='json_ext', xhr=True)
688 690 def pull_request_repo_destinations(self):
689 691 _ = self.request.translate
690 692 filter_query = self.request.GET.get('query')
691 693
692 694 query = Repository.query() \
693 695 .order_by(func.length(Repository.repo_name)) \
694 696 .filter(
695 697 or_(Repository.repo_name == self.db_repo.repo_name,
696 698 Repository.fork_id == self.db_repo.repo_id))
697 699
698 700 if filter_query:
699 701 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
700 702 query = query.filter(
701 703 Repository.repo_name.ilike(ilike_expression))
702 704
703 705 add_parent = False
704 706 if self.db_repo.parent:
705 707 if filter_query in self.db_repo.parent.repo_name:
706 708 parent_vcs_obj = self.db_repo.parent.scm_instance()
707 709 if parent_vcs_obj and not parent_vcs_obj.is_empty():
708 710 add_parent = True
709 711
710 712 limit = 20 - 1 if add_parent else 20
711 713 all_repos = query.limit(limit).all()
712 714 if add_parent:
713 715 all_repos += [self.db_repo.parent]
714 716
715 717 repos = []
716 718 for obj in ScmModel().get_repos(all_repos):
717 719 repos.append({
718 720 'id': obj['name'],
719 721 'text': obj['name'],
720 722 'type': 'repo',
721 723 'obj': obj['dbrepo']
722 724 })
723 725
724 726 data = {
725 727 'more': False,
726 728 'results': [{
727 729 'text': _('Repositories'),
728 730 'children': repos
729 731 }] if repos else []
730 732 }
731 733 return data
732 734
733 735 @LoginRequired()
734 736 @NotAnonymous()
735 737 @HasRepoPermissionAnyDecorator(
736 738 'repository.read', 'repository.write', 'repository.admin')
737 739 @CSRFRequired()
738 740 @view_config(
739 741 route_name='pullrequest_create', request_method='POST',
740 742 renderer=None)
741 743 def pull_request_create(self):
742 744 _ = self.request.translate
743 745 self.assure_not_empty_repo()
744 746
745 747 controls = peppercorn.parse(self.request.POST.items())
746 748
747 749 try:
748 750 _form = PullRequestForm(self.db_repo.repo_id)().to_python(controls)
749 751 except formencode.Invalid as errors:
750 752 if errors.error_dict.get('revisions'):
751 753 msg = 'Revisions: %s' % errors.error_dict['revisions']
752 754 elif errors.error_dict.get('pullrequest_title'):
753 755 msg = _('Pull request requires a title with min. 3 chars')
754 756 else:
755 757 msg = _('Error creating pull request: {}').format(errors)
756 758 log.exception(msg)
757 759 h.flash(msg, 'error')
758 760
759 761 # would rather just go back to form ...
760 762 raise HTTPFound(
761 763 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
762 764
763 765 source_repo = _form['source_repo']
764 766 source_ref = _form['source_ref']
765 767 target_repo = _form['target_repo']
766 768 target_ref = _form['target_ref']
767 769 commit_ids = _form['revisions'][::-1]
768 770
769 771 # find the ancestor for this pr
770 772 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
771 773 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
772 774
773 775 source_scm = source_db_repo.scm_instance()
774 776 target_scm = target_db_repo.scm_instance()
775 777
776 778 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
777 779 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
778 780
779 781 ancestor = source_scm.get_common_ancestor(
780 782 source_commit.raw_id, target_commit.raw_id, target_scm)
781 783
782 784 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
783 785 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
784 786
785 787 pullrequest_title = _form['pullrequest_title']
786 788 title_source_ref = source_ref.split(':', 2)[1]
787 789 if not pullrequest_title:
788 790 pullrequest_title = PullRequestModel().generate_pullrequest_title(
789 791 source=source_repo,
790 792 source_ref=title_source_ref,
791 793 target=target_repo
792 794 )
793 795
794 796 description = _form['pullrequest_desc']
795 797
796 798 get_default_reviewers_data, validate_default_reviewers = \
797 799 PullRequestModel().get_reviewer_functions()
798 800
799 801 # recalculate reviewers logic, to make sure we can validate this
800 802 reviewer_rules = get_default_reviewers_data(
801 803 self._rhodecode_db_user, source_db_repo,
802 804 source_commit, target_db_repo, target_commit)
803 805
804 806 given_reviewers = _form['review_members']
805 807 reviewers = validate_default_reviewers(given_reviewers, reviewer_rules)
806 808
807 809 try:
808 810 pull_request = PullRequestModel().create(
809 self._rhodecode_user.user_id, source_repo, source_ref, target_repo,
810 target_ref, commit_ids, reviewers, pullrequest_title,
811 description, reviewer_rules
811 self._rhodecode_user.user_id, source_repo, source_ref,
812 target_repo, target_ref, commit_ids, reviewers,
813 pullrequest_title, description, reviewer_rules
812 814 )
813 815 Session().commit()
816
814 817 h.flash(_('Successfully opened new pull request'),
815 818 category='success')
816 819 except Exception:
817 820 msg = _('Error occurred during creation of this pull request.')
818 821 log.exception(msg)
819 822 h.flash(msg, category='error')
820 823
821 824 # copy the args back to redirect
822 825 org_query = self.request.GET.mixed()
823 826 raise HTTPFound(
824 827 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
825 828 _query=org_query))
826 829
827 830 raise HTTPFound(
828 831 h.route_path('pullrequest_show', repo_name=target_repo,
829 832 pull_request_id=pull_request.pull_request_id))
830 833
831 834 @LoginRequired()
832 835 @NotAnonymous()
833 836 @HasRepoPermissionAnyDecorator(
834 837 'repository.read', 'repository.write', 'repository.admin')
835 838 @CSRFRequired()
836 839 @view_config(
837 840 route_name='pullrequest_update', request_method='POST',
838 841 renderer='json_ext')
839 842 def pull_request_update(self):
840 843 pull_request = PullRequest.get_or_404(
841 844 self.request.matchdict['pull_request_id'])
842 845
843 846 # only owner or admin can update it
844 847 allowed_to_update = PullRequestModel().check_user_update(
845 848 pull_request, self._rhodecode_user)
846 849 if allowed_to_update:
847 850 controls = peppercorn.parse(self.request.POST.items())
848 851
849 852 if 'review_members' in controls:
850 853 self._update_reviewers(
851 854 pull_request, controls['review_members'],
852 855 pull_request.reviewer_data)
853 856 elif str2bool(self.request.POST.get('update_commits', 'false')):
854 857 self._update_commits(pull_request)
855 858 elif str2bool(self.request.POST.get('edit_pull_request', 'false')):
856 859 self._edit_pull_request(pull_request)
857 860 else:
858 861 raise HTTPBadRequest()
859 862 return True
860 863 raise HTTPForbidden()
861 864
862 865 def _edit_pull_request(self, pull_request):
863 866 _ = self.request.translate
864 867 try:
865 868 PullRequestModel().edit(
866 869 pull_request, self.request.POST.get('title'),
867 870 self.request.POST.get('description'), self._rhodecode_user)
868 871 except ValueError:
869 872 msg = _(u'Cannot update closed pull requests.')
870 873 h.flash(msg, category='error')
871 874 return
872 875 else:
873 876 Session().commit()
874 877
875 878 msg = _(u'Pull request title & description updated.')
876 879 h.flash(msg, category='success')
877 880 return
878 881
879 882 def _update_commits(self, pull_request):
880 883 _ = self.request.translate
881 884 resp = PullRequestModel().update_commits(pull_request)
882 885
883 886 if resp.executed:
884 887
885 888 if resp.target_changed and resp.source_changed:
886 889 changed = 'target and source repositories'
887 890 elif resp.target_changed and not resp.source_changed:
888 891 changed = 'target repository'
889 892 elif not resp.target_changed and resp.source_changed:
890 893 changed = 'source repository'
891 894 else:
892 895 changed = 'nothing'
893 896
894 897 msg = _(
895 898 u'Pull request updated to "{source_commit_id}" with '
896 899 u'{count_added} added, {count_removed} removed commits. '
897 900 u'Source of changes: {change_source}')
898 901 msg = msg.format(
899 902 source_commit_id=pull_request.source_ref_parts.commit_id,
900 903 count_added=len(resp.changes.added),
901 904 count_removed=len(resp.changes.removed),
902 905 change_source=changed)
903 906 h.flash(msg, category='success')
904 907
905 908 channel = '/repo${}$/pr/{}'.format(
906 909 pull_request.target_repo.repo_name,
907 910 pull_request.pull_request_id)
908 911 message = msg + (
909 912 ' - <a onclick="window.location.reload()">'
910 913 '<strong>{}</strong></a>'.format(_('Reload page')))
911 914 channelstream.post_message(
912 915 channel, message, self._rhodecode_user.username,
913 916 registry=self.request.registry)
914 917 else:
915 918 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
916 919 warning_reasons = [
917 920 UpdateFailureReason.NO_CHANGE,
918 921 UpdateFailureReason.WRONG_REF_TYPE,
919 922 ]
920 923 category = 'warning' if resp.reason in warning_reasons else 'error'
921 924 h.flash(msg, category=category)
922 925
923 926 @LoginRequired()
924 927 @NotAnonymous()
925 928 @HasRepoPermissionAnyDecorator(
926 929 'repository.read', 'repository.write', 'repository.admin')
927 930 @CSRFRequired()
928 931 @view_config(
929 932 route_name='pullrequest_merge', request_method='POST',
930 933 renderer='json_ext')
931 934 def pull_request_merge(self):
932 935 """
933 936 Merge will perform a server-side merge of the specified
934 937 pull request, if the pull request is approved and mergeable.
935 938 After successful merging, the pull request is automatically
936 939 closed, with a relevant comment.
937 940 """
938 941 pull_request = PullRequest.get_or_404(
939 942 self.request.matchdict['pull_request_id'])
940 943
941 check = MergeCheck.validate(pull_request, self._rhodecode_db_user)
944 check = MergeCheck.validate(pull_request, self._rhodecode_db_user,
945 translator=self.request.translate)
942 946 merge_possible = not check.failed
943 947
944 948 for err_type, error_msg in check.errors:
945 949 h.flash(error_msg, category=err_type)
946 950
947 951 if merge_possible:
948 952 log.debug("Pre-conditions checked, trying to merge.")
949 953 extras = vcs_operation_context(
950 954 self.request.environ, repo_name=pull_request.target_repo.repo_name,
951 955 username=self._rhodecode_db_user.username, action='push',
952 956 scm=pull_request.target_repo.repo_type)
953 957 self._merge_pull_request(
954 958 pull_request, self._rhodecode_db_user, extras)
955 959 else:
956 960 log.debug("Pre-conditions failed, NOT merging.")
957 961
958 962 raise HTTPFound(
959 963 h.route_path('pullrequest_show',
960 964 repo_name=pull_request.target_repo.repo_name,
961 965 pull_request_id=pull_request.pull_request_id))
962 966
963 967 def _merge_pull_request(self, pull_request, user, extras):
964 968 _ = self.request.translate
965 969 merge_resp = PullRequestModel().merge(pull_request, user, extras=extras)
966 970
967 971 if merge_resp.executed:
968 972 log.debug("The merge was successful, closing the pull request.")
969 973 PullRequestModel().close_pull_request(
970 974 pull_request.pull_request_id, user)
971 975 Session().commit()
972 976 msg = _('Pull request was successfully merged and closed.')
973 977 h.flash(msg, category='success')
974 978 else:
975 979 log.debug(
976 980 "The merge was not successful. Merge response: %s",
977 981 merge_resp)
978 982 msg = PullRequestModel().merge_status_message(
979 983 merge_resp.failure_reason)
980 984 h.flash(msg, category='error')
981 985
982 986 def _update_reviewers(self, pull_request, review_members, reviewer_rules):
983 987 _ = self.request.translate
984 988 get_default_reviewers_data, validate_default_reviewers = \
985 989 PullRequestModel().get_reviewer_functions()
986 990
987 991 try:
988 992 reviewers = validate_default_reviewers(review_members, reviewer_rules)
989 993 except ValueError as e:
990 994 log.error('Reviewers Validation: {}'.format(e))
991 995 h.flash(e, category='error')
992 996 return
993 997
994 998 PullRequestModel().update_reviewers(
995 999 pull_request, reviewers, self._rhodecode_user)
996 1000 h.flash(_('Pull request reviewers updated.'), category='success')
997 1001 Session().commit()
998 1002
999 1003 @LoginRequired()
1000 1004 @NotAnonymous()
1001 1005 @HasRepoPermissionAnyDecorator(
1002 1006 'repository.read', 'repository.write', 'repository.admin')
1003 1007 @CSRFRequired()
1004 1008 @view_config(
1005 1009 route_name='pullrequest_delete', request_method='POST',
1006 1010 renderer='json_ext')
1007 1011 def pull_request_delete(self):
1008 1012 _ = self.request.translate
1009 1013
1010 1014 pull_request = PullRequest.get_or_404(
1011 1015 self.request.matchdict['pull_request_id'])
1012 1016
1013 1017 pr_closed = pull_request.is_closed()
1014 1018 allowed_to_delete = PullRequestModel().check_user_delete(
1015 1019 pull_request, self._rhodecode_user) and not pr_closed
1016 1020
1017 1021 # only owner can delete it !
1018 1022 if allowed_to_delete:
1019 1023 PullRequestModel().delete(pull_request, self._rhodecode_user)
1020 1024 Session().commit()
1021 1025 h.flash(_('Successfully deleted pull request'),
1022 1026 category='success')
1023 1027 raise HTTPFound(h.route_path('pullrequest_show_all',
1024 1028 repo_name=self.db_repo_name))
1025 1029
1026 1030 log.warning('user %s tried to delete pull request without access',
1027 1031 self._rhodecode_user)
1028 1032 raise HTTPNotFound()
1029 1033
1030 1034 @LoginRequired()
1031 1035 @NotAnonymous()
1032 1036 @HasRepoPermissionAnyDecorator(
1033 1037 'repository.read', 'repository.write', 'repository.admin')
1034 1038 @CSRFRequired()
1035 1039 @view_config(
1036 1040 route_name='pullrequest_comment_create', request_method='POST',
1037 1041 renderer='json_ext')
1038 1042 def pull_request_comment_create(self):
1039 1043 _ = self.request.translate
1040 1044
1041 1045 pull_request = PullRequest.get_or_404(
1042 1046 self.request.matchdict['pull_request_id'])
1043 1047 pull_request_id = pull_request.pull_request_id
1044 1048
1045 1049 if pull_request.is_closed():
1046 1050 log.debug('comment: forbidden because pull request is closed')
1047 1051 raise HTTPForbidden()
1048 1052
1049 1053 c = self.load_default_context()
1050 1054
1051 1055 status = self.request.POST.get('changeset_status', None)
1052 1056 text = self.request.POST.get('text')
1053 1057 comment_type = self.request.POST.get('comment_type')
1054 1058 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
1055 1059 close_pull_request = self.request.POST.get('close_pull_request')
1056 1060
1057 1061 # the logic here should work like following, if we submit close
1058 1062 # pr comment, use `close_pull_request_with_comment` function
1059 1063 # else handle regular comment logic
1060 1064
1061 1065 if close_pull_request:
1062 1066 # only owner or admin or person with write permissions
1063 1067 allowed_to_close = PullRequestModel().check_user_update(
1064 1068 pull_request, self._rhodecode_user)
1065 1069 if not allowed_to_close:
1066 1070 log.debug('comment: forbidden because not allowed to close '
1067 1071 'pull request %s', pull_request_id)
1068 1072 raise HTTPForbidden()
1069 1073 comment, status = PullRequestModel().close_pull_request_with_comment(
1070 1074 pull_request, self._rhodecode_user, self.db_repo, message=text)
1071 1075 Session().flush()
1072 1076 events.trigger(
1073 1077 events.PullRequestCommentEvent(pull_request, comment))
1074 1078
1075 1079 else:
1076 1080 # regular comment case, could be inline, or one with status.
1077 1081 # for that one we check also permissions
1078 1082
1079 1083 allowed_to_change_status = PullRequestModel().check_user_change_status(
1080 1084 pull_request, self._rhodecode_user)
1081 1085
1082 1086 if status and allowed_to_change_status:
1083 1087 message = (_('Status change %(transition_icon)s %(status)s')
1084 1088 % {'transition_icon': '>',
1085 1089 'status': ChangesetStatus.get_status_lbl(status)})
1086 1090 text = text or message
1087 1091
1088 1092 comment = CommentsModel().create(
1089 1093 text=text,
1090 1094 repo=self.db_repo.repo_id,
1091 1095 user=self._rhodecode_user.user_id,
1092 1096 pull_request=pull_request,
1093 1097 f_path=self.request.POST.get('f_path'),
1094 1098 line_no=self.request.POST.get('line'),
1095 1099 status_change=(ChangesetStatus.get_status_lbl(status)
1096 1100 if status and allowed_to_change_status else None),
1097 1101 status_change_type=(status
1098 1102 if status and allowed_to_change_status else None),
1099 1103 comment_type=comment_type,
1100 1104 resolves_comment_id=resolves_comment_id
1101 1105 )
1102 1106
1103 1107 if allowed_to_change_status:
1104 1108 # calculate old status before we change it
1105 1109 old_calculated_status = pull_request.calculated_review_status()
1106 1110
1107 1111 # get status if set !
1108 1112 if status:
1109 1113 ChangesetStatusModel().set_status(
1110 1114 self.db_repo.repo_id,
1111 1115 status,
1112 1116 self._rhodecode_user.user_id,
1113 1117 comment,
1114 1118 pull_request=pull_request
1115 1119 )
1116 1120
1117 1121 Session().flush()
1118 1122 events.trigger(
1119 1123 events.PullRequestCommentEvent(pull_request, comment))
1120 1124
1121 1125 # we now calculate the status of pull request, and based on that
1122 1126 # calculation we set the commits status
1123 1127 calculated_status = pull_request.calculated_review_status()
1124 1128 if old_calculated_status != calculated_status:
1125 1129 PullRequestModel()._trigger_pull_request_hook(
1126 1130 pull_request, self._rhodecode_user, 'review_status_change')
1127 1131
1128 1132 Session().commit()
1129 1133
1130 1134 data = {
1131 1135 'target_id': h.safeid(h.safe_unicode(
1132 1136 self.request.POST.get('f_path'))),
1133 1137 }
1134 1138 if comment:
1135 1139 c.co = comment
1136 1140 rendered_comment = render(
1137 1141 'rhodecode:templates/changeset/changeset_comment_block.mako',
1138 1142 self._get_template_context(c), self.request)
1139 1143
1140 1144 data.update(comment.get_dict())
1141 1145 data.update({'rendered_text': rendered_comment})
1142 1146
1143 1147 return data
1144 1148
1145 1149 @LoginRequired()
1146 1150 @NotAnonymous()
1147 1151 @HasRepoPermissionAnyDecorator(
1148 1152 'repository.read', 'repository.write', 'repository.admin')
1149 1153 @CSRFRequired()
1150 1154 @view_config(
1151 1155 route_name='pullrequest_comment_delete', request_method='POST',
1152 1156 renderer='json_ext')
1153 1157 def pull_request_comment_delete(self):
1154 1158 pull_request = PullRequest.get_or_404(
1155 1159 self.request.matchdict['pull_request_id'])
1156 1160
1157 1161 comment = ChangesetComment.get_or_404(
1158 1162 self.request.matchdict['comment_id'])
1159 1163 comment_id = comment.comment_id
1160 1164
1161 1165 if pull_request.is_closed():
1162 1166 log.debug('comment: forbidden because pull request is closed')
1163 1167 raise HTTPForbidden()
1164 1168
1165 1169 if not comment:
1166 1170 log.debug('Comment with id:%s not found, skipping', comment_id)
1167 1171 # comment already deleted in another call probably
1168 1172 return True
1169 1173
1170 1174 if comment.pull_request.is_closed():
1171 1175 # don't allow deleting comments on closed pull request
1172 1176 raise HTTPForbidden()
1173 1177
1174 1178 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1175 1179 super_admin = h.HasPermissionAny('hg.admin')()
1176 1180 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1177 1181 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1178 1182 comment_repo_admin = is_repo_admin and is_repo_comment
1179 1183
1180 1184 if super_admin or comment_owner or comment_repo_admin:
1181 1185 old_calculated_status = comment.pull_request.calculated_review_status()
1182 1186 CommentsModel().delete(comment=comment, user=self._rhodecode_user)
1183 1187 Session().commit()
1184 1188 calculated_status = comment.pull_request.calculated_review_status()
1185 1189 if old_calculated_status != calculated_status:
1186 1190 PullRequestModel()._trigger_pull_request_hook(
1187 1191 comment.pull_request, self._rhodecode_user, 'review_status_change')
1188 1192 return True
1189 1193 else:
1190 1194 log.warning('No permissions for user %s to delete comment_id: %s',
1191 1195 self._rhodecode_db_user, comment_id)
1192 1196 raise HTTPNotFound()
@@ -1,631 +1,635 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 The base Controller API
23 23 Provides the BaseController class for subclassing. And usage in different
24 24 controllers
25 25 """
26 26
27 27 import logging
28 28 import socket
29 29
30 30 import markupsafe
31 31 import ipaddress
32 32 import pyramid.threadlocal
33 33
34 34 from paste.auth.basic import AuthBasicAuthenticator
35 35 from paste.httpexceptions import HTTPUnauthorized, HTTPForbidden, get_exception
36 36 from paste.httpheaders import WWW_AUTHENTICATE, AUTHORIZATION
37 37
38 38 import rhodecode
39 39 from rhodecode.authentication.base import VCS_TYPE
40 40 from rhodecode.lib import auth, utils2
41 41 from rhodecode.lib import helpers as h
42 42 from rhodecode.lib.auth import AuthUser, CookieStoreWrapper
43 43 from rhodecode.lib.exceptions import UserCreationError
44 44 from rhodecode.lib.utils import (
45 45 get_repo_slug, set_rhodecode_config, password_changed,
46 46 get_enabled_hook_classes)
47 47 from rhodecode.lib.utils2 import (
48 48 str2bool, safe_unicode, AttributeDict, safe_int, md5, aslist, safe_str)
49 49 from rhodecode.model import meta
50 50 from rhodecode.model.db import Repository, User, ChangesetComment
51 51 from rhodecode.model.notification import NotificationModel
52 52 from rhodecode.model.scm import ScmModel
53 53 from rhodecode.model.settings import VcsSettingsModel, SettingsModel
54 54
55 55 # NOTE(marcink): remove after base controller is no longer required
56 56 from pylons.controllers import WSGIController
57 57 from pylons.i18n import translation
58 58
59 59 log = logging.getLogger(__name__)
60 60
61 61
62 62 # hack to make the migration to pyramid easier
63 63 def render(template_name, extra_vars=None, cache_key=None,
64 64 cache_type=None, cache_expire=None):
65 65 """Render a template with Mako
66 66
67 67 Accepts the cache options ``cache_key``, ``cache_type``, and
68 68 ``cache_expire``.
69 69
70 70 """
71 71 from pylons.templating import literal
72 72 from pylons.templating import cached_template, pylons_globals
73 73
74 74 # Create a render callable for the cache function
75 75 def render_template():
76 76 # Pull in extra vars if needed
77 77 globs = extra_vars or {}
78 78
79 79 # Second, get the globals
80 80 globs.update(pylons_globals())
81 81
82 82 globs['_ungettext'] = globs['ungettext']
83 83 # Grab a template reference
84 84 template = globs['app_globals'].mako_lookup.get_template(template_name)
85 85
86 86 return literal(template.render_unicode(**globs))
87 87
88 88 return cached_template(template_name, render_template, cache_key=cache_key,
89 89 cache_type=cache_type, cache_expire=cache_expire)
90 90
91 91 def _filter_proxy(ip):
92 92 """
93 93 Passed in IP addresses in HEADERS can be in a special format of multiple
94 94 ips. Those comma separated IPs are passed from various proxies in the
95 95 chain of request processing. The left-most being the original client.
96 96 We only care about the first IP which came from the org. client.
97 97
98 98 :param ip: ip string from headers
99 99 """
100 100 if ',' in ip:
101 101 _ips = ip.split(',')
102 102 _first_ip = _ips[0].strip()
103 103 log.debug('Got multiple IPs %s, using %s', ','.join(_ips), _first_ip)
104 104 return _first_ip
105 105 return ip
106 106
107 107
108 108 def _filter_port(ip):
109 109 """
110 110 Removes a port from ip, there are 4 main cases to handle here.
111 111 - ipv4 eg. 127.0.0.1
112 112 - ipv6 eg. ::1
113 113 - ipv4+port eg. 127.0.0.1:8080
114 114 - ipv6+port eg. [::1]:8080
115 115
116 116 :param ip:
117 117 """
118 118 def is_ipv6(ip_addr):
119 119 if hasattr(socket, 'inet_pton'):
120 120 try:
121 121 socket.inet_pton(socket.AF_INET6, ip_addr)
122 122 except socket.error:
123 123 return False
124 124 else:
125 125 # fallback to ipaddress
126 126 try:
127 127 ipaddress.IPv6Address(safe_unicode(ip_addr))
128 128 except Exception:
129 129 return False
130 130 return True
131 131
132 132 if ':' not in ip: # must be ipv4 pure ip
133 133 return ip
134 134
135 135 if '[' in ip and ']' in ip: # ipv6 with port
136 136 return ip.split(']')[0][1:].lower()
137 137
138 138 # must be ipv6 or ipv4 with port
139 139 if is_ipv6(ip):
140 140 return ip
141 141 else:
142 142 ip, _port = ip.split(':')[:2] # means ipv4+port
143 143 return ip
144 144
145 145
146 146 def get_ip_addr(environ):
147 147 proxy_key = 'HTTP_X_REAL_IP'
148 148 proxy_key2 = 'HTTP_X_FORWARDED_FOR'
149 149 def_key = 'REMOTE_ADDR'
150 150 _filters = lambda x: _filter_port(_filter_proxy(x))
151 151
152 152 ip = environ.get(proxy_key)
153 153 if ip:
154 154 return _filters(ip)
155 155
156 156 ip = environ.get(proxy_key2)
157 157 if ip:
158 158 return _filters(ip)
159 159
160 160 ip = environ.get(def_key, '0.0.0.0')
161 161 return _filters(ip)
162 162
163 163
164 164 def get_server_ip_addr(environ, log_errors=True):
165 165 hostname = environ.get('SERVER_NAME')
166 166 try:
167 167 return socket.gethostbyname(hostname)
168 168 except Exception as e:
169 169 if log_errors:
170 170 # in some cases this lookup is not possible, and we don't want to
171 171 # make it an exception in logs
172 172 log.exception('Could not retrieve server ip address: %s', e)
173 173 return hostname
174 174
175 175
176 176 def get_server_port(environ):
177 177 return environ.get('SERVER_PORT')
178 178
179 179
180 180 def get_access_path(environ):
181 181 path = environ.get('PATH_INFO')
182 182 org_req = environ.get('pylons.original_request')
183 183 if org_req:
184 184 path = org_req.environ.get('PATH_INFO')
185 185 return path
186 186
187 187
188 188 def get_user_agent(environ):
189 189 return environ.get('HTTP_USER_AGENT')
190 190
191 191
192 192 def vcs_operation_context(
193 193 environ, repo_name, username, action, scm, check_locking=True,
194 194 is_shadow_repo=False):
195 195 """
196 196 Generate the context for a vcs operation, e.g. push or pull.
197 197
198 198 This context is passed over the layers so that hooks triggered by the
199 199 vcs operation know details like the user, the user's IP address etc.
200 200
201 201 :param check_locking: Allows to switch of the computation of the locking
202 202 data. This serves mainly the need of the simplevcs middleware to be
203 203 able to disable this for certain operations.
204 204
205 205 """
206 206 # Tri-state value: False: unlock, None: nothing, True: lock
207 207 make_lock = None
208 208 locked_by = [None, None, None]
209 209 is_anonymous = username == User.DEFAULT_USER
210 210 if not is_anonymous and check_locking:
211 211 log.debug('Checking locking on repository "%s"', repo_name)
212 212 user = User.get_by_username(username)
213 213 repo = Repository.get_by_repo_name(repo_name)
214 214 make_lock, __, locked_by = repo.get_locking_state(
215 215 action, user.user_id)
216 216
217 217 settings_model = VcsSettingsModel(repo=repo_name)
218 218 ui_settings = settings_model.get_ui_settings()
219 219
220 220 extras = {
221 221 'ip': get_ip_addr(environ),
222 222 'username': username,
223 223 'action': action,
224 224 'repository': repo_name,
225 225 'scm': scm,
226 226 'config': rhodecode.CONFIG['__file__'],
227 227 'make_lock': make_lock,
228 228 'locked_by': locked_by,
229 229 'server_url': utils2.get_server_url(environ),
230 230 'user_agent': get_user_agent(environ),
231 231 'hooks': get_enabled_hook_classes(ui_settings),
232 232 'is_shadow_repo': is_shadow_repo,
233 233 }
234 234 return extras
235 235
236 236
237 237 class BasicAuth(AuthBasicAuthenticator):
238 238
239 239 def __init__(self, realm, authfunc, registry, auth_http_code=None,
240 240 initial_call_detection=False, acl_repo_name=None):
241 241 self.realm = realm
242 242 self.initial_call = initial_call_detection
243 243 self.authfunc = authfunc
244 244 self.registry = registry
245 245 self.acl_repo_name = acl_repo_name
246 246 self._rc_auth_http_code = auth_http_code
247 247
248 248 def _get_response_from_code(self, http_code):
249 249 try:
250 250 return get_exception(safe_int(http_code))
251 251 except Exception:
252 252 log.exception('Failed to fetch response for code %s' % http_code)
253 253 return HTTPForbidden
254 254
255 255 def get_rc_realm(self):
256 256 return safe_str(self.registry.rhodecode_settings.get('rhodecode_realm'))
257 257
258 258 def build_authentication(self):
259 259 head = WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm)
260 260 if self._rc_auth_http_code and not self.initial_call:
261 261 # return alternative HTTP code if alternative http return code
262 262 # is specified in RhodeCode config, but ONLY if it's not the
263 263 # FIRST call
264 264 custom_response_klass = self._get_response_from_code(
265 265 self._rc_auth_http_code)
266 266 return custom_response_klass(headers=head)
267 267 return HTTPUnauthorized(headers=head)
268 268
269 269 def authenticate(self, environ):
270 270 authorization = AUTHORIZATION(environ)
271 271 if not authorization:
272 272 return self.build_authentication()
273 273 (authmeth, auth) = authorization.split(' ', 1)
274 274 if 'basic' != authmeth.lower():
275 275 return self.build_authentication()
276 276 auth = auth.strip().decode('base64')
277 277 _parts = auth.split(':', 1)
278 278 if len(_parts) == 2:
279 279 username, password = _parts
280 280 auth_data = self.authfunc(
281 281 username, password, environ, VCS_TYPE,
282 282 registry=self.registry, acl_repo_name=self.acl_repo_name)
283 283 if auth_data:
284 284 return {'username': username, 'auth_data': auth_data}
285 285 if username and password:
286 286 # we mark that we actually executed authentication once, at
287 287 # that point we can use the alternative auth code
288 288 self.initial_call = False
289 289
290 290 return self.build_authentication()
291 291
292 292 __call__ = authenticate
293 293
294 294
295 295 def calculate_version_hash(config):
296 296 return md5(
297 297 config.get('beaker.session.secret', '') +
298 298 rhodecode.__version__)[:8]
299 299
300 300
301 301 def get_current_lang(request):
302 302 # NOTE(marcink): remove after pyramid move
303 303 try:
304 304 return translation.get_lang()[0]
305 305 except:
306 306 pass
307 307
308 308 return getattr(request, '_LOCALE_', request.locale_name)
309 309
310 310
311 311 def attach_context_attributes(context, request, user_id):
312 312 """
313 313 Attach variables into template context called `c`, please note that
314 314 request could be pylons or pyramid request in here.
315 315 """
316 316 # NOTE(marcink): remove check after pyramid migration
317 317 if hasattr(request, 'registry'):
318 318 config = request.registry.settings
319 319 else:
320 320 from pylons import config
321 321
322 322 rc_config = SettingsModel().get_all_settings(cache=True)
323 323
324 324 context.rhodecode_version = rhodecode.__version__
325 325 context.rhodecode_edition = config.get('rhodecode.edition')
326 326 # unique secret + version does not leak the version but keep consistency
327 327 context.rhodecode_version_hash = calculate_version_hash(config)
328 328
329 329 # Default language set for the incoming request
330 330 context.language = get_current_lang(request)
331 331
332 332 # Visual options
333 333 context.visual = AttributeDict({})
334 334
335 335 # DB stored Visual Items
336 336 context.visual.show_public_icon = str2bool(
337 337 rc_config.get('rhodecode_show_public_icon'))
338 338 context.visual.show_private_icon = str2bool(
339 339 rc_config.get('rhodecode_show_private_icon'))
340 340 context.visual.stylify_metatags = str2bool(
341 341 rc_config.get('rhodecode_stylify_metatags'))
342 342 context.visual.dashboard_items = safe_int(
343 343 rc_config.get('rhodecode_dashboard_items', 100))
344 344 context.visual.admin_grid_items = safe_int(
345 345 rc_config.get('rhodecode_admin_grid_items', 100))
346 346 context.visual.repository_fields = str2bool(
347 347 rc_config.get('rhodecode_repository_fields'))
348 348 context.visual.show_version = str2bool(
349 349 rc_config.get('rhodecode_show_version'))
350 350 context.visual.use_gravatar = str2bool(
351 351 rc_config.get('rhodecode_use_gravatar'))
352 352 context.visual.gravatar_url = rc_config.get('rhodecode_gravatar_url')
353 353 context.visual.default_renderer = rc_config.get(
354 354 'rhodecode_markup_renderer', 'rst')
355 355 context.visual.comment_types = ChangesetComment.COMMENT_TYPES
356 356 context.visual.rhodecode_support_url = \
357 357 rc_config.get('rhodecode_support_url') or h.route_url('rhodecode_support')
358 358
359 359 context.visual.affected_files_cut_off = 60
360 360
361 361 context.pre_code = rc_config.get('rhodecode_pre_code')
362 362 context.post_code = rc_config.get('rhodecode_post_code')
363 363 context.rhodecode_name = rc_config.get('rhodecode_title')
364 364 context.default_encodings = aslist(config.get('default_encoding'), sep=',')
365 365 # if we have specified default_encoding in the request, it has more
366 366 # priority
367 367 if request.GET.get('default_encoding'):
368 368 context.default_encodings.insert(0, request.GET.get('default_encoding'))
369 369 context.clone_uri_tmpl = rc_config.get('rhodecode_clone_uri_tmpl')
370 370
371 371 # INI stored
372 372 context.labs_active = str2bool(
373 373 config.get('labs_settings_active', 'false'))
374 374 context.visual.allow_repo_location_change = str2bool(
375 375 config.get('allow_repo_location_change', True))
376 376 context.visual.allow_custom_hooks_settings = str2bool(
377 377 config.get('allow_custom_hooks_settings', True))
378 378 context.debug_style = str2bool(config.get('debug_style', False))
379 379
380 380 context.rhodecode_instanceid = config.get('instance_id')
381 381
382 382 context.visual.cut_off_limit_diff = safe_int(
383 383 config.get('cut_off_limit_diff'))
384 384 context.visual.cut_off_limit_file = safe_int(
385 385 config.get('cut_off_limit_file'))
386 386
387 387 # AppEnlight
388 388 context.appenlight_enabled = str2bool(config.get('appenlight', 'false'))
389 389 context.appenlight_api_public_key = config.get(
390 390 'appenlight.api_public_key', '')
391 391 context.appenlight_server_url = config.get('appenlight.server_url', '')
392 392
393 393 # JS template context
394 394 context.template_context = {
395 395 'repo_name': None,
396 396 'repo_type': None,
397 397 'repo_landing_commit': None,
398 398 'rhodecode_user': {
399 399 'username': None,
400 400 'email': None,
401 401 'notification_status': False
402 402 },
403 403 'visual': {
404 404 'default_renderer': None
405 405 },
406 406 'commit_data': {
407 407 'commit_id': None
408 408 },
409 409 'pull_request_data': {'pull_request_id': None},
410 410 'timeago': {
411 411 'refresh_time': 120 * 1000,
412 412 'cutoff_limit': 1000 * 60 * 60 * 24 * 7
413 413 },
414 414 'pyramid_dispatch': {
415 415
416 416 },
417 417 'extra': {'plugins': {}}
418 418 }
419 419 # END CONFIG VARS
420 420
421 421 # TODO: This dosn't work when called from pylons compatibility tween.
422 422 # Fix this and remove it from base controller.
423 423 # context.repo_name = get_repo_slug(request) # can be empty
424 424
425 425 diffmode = 'sideside'
426 426 if request.GET.get('diffmode'):
427 427 if request.GET['diffmode'] == 'unified':
428 428 diffmode = 'unified'
429 429 elif request.session.get('diffmode'):
430 430 diffmode = request.session['diffmode']
431 431
432 432 context.diffmode = diffmode
433 433
434 434 if request.session.get('diffmode') != diffmode:
435 435 request.session['diffmode'] = diffmode
436 436
437 437 context.csrf_token = auth.get_csrf_token(session=request.session)
438 438 context.backends = rhodecode.BACKENDS.keys()
439 439 context.backends.sort()
440 440 context.unread_notifications = NotificationModel().get_unread_cnt_for_user(user_id)
441 441
442 442 # NOTE(marcink): when migrated to pyramid we don't need to set this anymore,
443 443 # given request will ALWAYS be pyramid one
444 444 pyramid_request = pyramid.threadlocal.get_current_request()
445 445 context.pyramid_request = pyramid_request
446 446
447 447 # web case
448 448 if hasattr(pyramid_request, 'user'):
449 449 context.auth_user = pyramid_request.user
450 450 context.rhodecode_user = pyramid_request.user
451 451
452 452 # api case
453 453 if hasattr(pyramid_request, 'rpc_user'):
454 454 context.auth_user = pyramid_request.rpc_user
455 455 context.rhodecode_user = pyramid_request.rpc_user
456 456
457 457 # attach the whole call context to the request
458 458 request.call_context = context
459 459
460 460
461 461 def get_auth_user(request):
462 462 environ = request.environ
463 463 session = request.session
464 464
465 465 ip_addr = get_ip_addr(environ)
466 466 # make sure that we update permissions each time we call controller
467 467 _auth_token = (request.GET.get('auth_token', '') or
468 468 request.GET.get('api_key', ''))
469 469
470 470 if _auth_token:
471 471 # when using API_KEY we assume user exists, and
472 472 # doesn't need auth based on cookies.
473 473 auth_user = AuthUser(api_key=_auth_token, ip_addr=ip_addr)
474 474 authenticated = False
475 475 else:
476 476 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
477 477 try:
478 478 auth_user = AuthUser(user_id=cookie_store.get('user_id', None),
479 479 ip_addr=ip_addr)
480 480 except UserCreationError as e:
481 481 h.flash(e, 'error')
482 482 # container auth or other auth functions that create users
483 483 # on the fly can throw this exception signaling that there's
484 484 # issue with user creation, explanation should be provided
485 485 # in Exception itself. We then create a simple blank
486 486 # AuthUser
487 487 auth_user = AuthUser(ip_addr=ip_addr)
488 488
489 489 if password_changed(auth_user, session):
490 490 session.invalidate()
491 491 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
492 492 auth_user = AuthUser(ip_addr=ip_addr)
493 493
494 494 authenticated = cookie_store.get('is_authenticated')
495 495
496 496 if not auth_user.is_authenticated and auth_user.is_user_object:
497 497 # user is not authenticated and not empty
498 498 auth_user.set_authenticated(authenticated)
499 499
500 500 return auth_user
501 501
502 502
503 503 class BaseController(WSGIController):
504 504
505 505 def __before__(self):
506 506 """
507 507 __before__ is called before controller methods and after __call__
508 508 """
509 509 # on each call propagate settings calls into global settings.
510 510 from pylons import config
511 511 from pylons import tmpl_context as c, request, url
512 512 set_rhodecode_config(config)
513 513 attach_context_attributes(c, request, self._rhodecode_user.user_id)
514 514
515 515 # TODO: Remove this when fixed in attach_context_attributes()
516 516 c.repo_name = get_repo_slug(request) # can be empty
517 517
518 518 self.cut_off_limit_diff = safe_int(config.get('cut_off_limit_diff'))
519 519 self.cut_off_limit_file = safe_int(config.get('cut_off_limit_file'))
520 520 self.sa = meta.Session
521 521 self.scm_model = ScmModel(self.sa)
522 522
523 523 # set user language
524 524 user_lang = getattr(c.pyramid_request, '_LOCALE_', None)
525 525 if user_lang:
526 526 translation.set_lang(user_lang)
527 527 log.debug('set language to %s for user %s',
528 528 user_lang, self._rhodecode_user)
529 529
530 530 def _dispatch_redirect(self, with_url, environ, start_response):
531 531 from webob.exc import HTTPFound
532 532 resp = HTTPFound(with_url)
533 533 environ['SCRIPT_NAME'] = '' # handle prefix middleware
534 534 environ['PATH_INFO'] = with_url
535 535 return resp(environ, start_response)
536 536
537 537 def __call__(self, environ, start_response):
538 538 """Invoke the Controller"""
539 539 # WSGIController.__call__ dispatches to the Controller method
540 540 # the request is routed to. This routing information is
541 541 # available in environ['pylons.routes_dict']
542 542 from rhodecode.lib import helpers as h
543 543 from pylons import tmpl_context as c, request, url
544 544
545 545 # Provide the Pylons context to Pyramid's debugtoolbar if it asks
546 546 if environ.get('debugtoolbar.wants_pylons_context', False):
547 547 environ['debugtoolbar.pylons_context'] = c._current_obj()
548 548
549 549 _route_name = '.'.join([environ['pylons.routes_dict']['controller'],
550 550 environ['pylons.routes_dict']['action']])
551 551
552 552 self.rc_config = SettingsModel().get_all_settings(cache=True)
553 553 self.ip_addr = get_ip_addr(environ)
554 554
555 555 # The rhodecode auth user is looked up and passed through the
556 556 # environ by the pylons compatibility tween in pyramid.
557 557 # So we can just grab it from there.
558 558 auth_user = environ['rc_auth_user']
559 559
560 560 # set globals for auth user
561 561 request.user = auth_user
562 562 self._rhodecode_user = auth_user
563 563
564 564 log.info('IP: %s User: %s accessed %s [%s]' % (
565 565 self.ip_addr, auth_user, safe_unicode(get_access_path(environ)),
566 566 _route_name)
567 567 )
568 568
569 569 user_obj = auth_user.get_instance()
570 570 if user_obj and user_obj.user_data.get('force_password_change'):
571 571 h.flash('You are required to change your password', 'warning',
572 572 ignore_duplicate=True)
573 573 return self._dispatch_redirect(
574 574 url('my_account_password'), environ, start_response)
575 575
576 576 return WSGIController.__call__(self, environ, start_response)
577 577
578 578
579 579 def h_filter(s):
580 580 """
581 581 Custom filter for Mako templates. Mako by standard uses `markupsafe.escape`
582 582 we wrap this with additional functionality that converts None to empty
583 583 strings
584 584 """
585 585 if s is None:
586 586 return markupsafe.Markup()
587 587 return markupsafe.escape(s)
588 588
589 589
590 590 def add_events_routes(config):
591 591 """
592 592 Adds routing that can be used in events. Because some events are triggered
593 593 outside of pyramid context, we need to bootstrap request with some
594 594 routing registered
595 595 """
596 596 config.add_route(name='home', pattern='/')
597 597
598 598 config.add_route(name='repo_summary', pattern='/{repo_name}')
599 599 config.add_route(name='repo_summary_explicit', pattern='/{repo_name}/summary')
600 600 config.add_route(name='repo_group_home', pattern='/{repo_group_name}')
601 601
602 602 config.add_route(name='pullrequest_show',
603 603 pattern='/{repo_name}/pull-request/{pull_request_id}')
604 604 config.add_route(name='pull_requests_global',
605 605 pattern='/pull-request/{pull_request_id}')
606 606
607 607 config.add_route(name='repo_commit',
608 608 pattern='/{repo_name}/changeset/{commit_id}')
609 609 config.add_route(name='repo_files',
610 610 pattern='/{repo_name}/files/{commit_id}/{f_path}')
611 611
612 612
613 613 def bootstrap_request(**kwargs):
614 614 import pyramid.testing
615 615
616 616 class TestRequest(pyramid.testing.DummyRequest):
617 617 application_url = kwargs.pop('application_url', 'http://example.com')
618 618 host = kwargs.pop('host', 'example.com:80')
619 619 domain = kwargs.pop('domain', 'example.com')
620 620
621 def translate(self, msg):
622 return msg
623
621 624 class TestDummySession(pyramid.testing.DummySession):
622 625 def save(*arg, **kw):
623 626 pass
624 627
625 628 request = TestRequest(**kwargs)
626 629 request.session = TestDummySession()
627 630
631
628 632 config = pyramid.testing.setUp(request=request)
629 633 add_events_routes(config)
630 634 return request
631 635
@@ -1,1611 +1,1622 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 from collections import namedtuple
26
27 27 import json
28 28 import logging
29 29 import datetime
30 30 import urllib
31 import collections
31 32
32 from pylons.i18n.translation import _
33 from pylons.i18n.translation import lazy_ugettext
34 33 from pyramid.threadlocal import get_current_request
35 from sqlalchemy import or_
36 34
37 35 from rhodecode import events
36 from rhodecode.translation import lazy_ugettext#, _
38 37 from rhodecode.lib import helpers as h, hooks_utils, diffs
39 38 from rhodecode.lib import audit_logger
40 39 from rhodecode.lib.compat import OrderedDict
41 40 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
42 41 from rhodecode.lib.markup_renderer import (
43 42 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
44 43 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
45 44 from rhodecode.lib.vcs.backends.base import (
46 45 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
47 46 from rhodecode.lib.vcs.conf import settings as vcs_settings
48 47 from rhodecode.lib.vcs.exceptions import (
49 48 CommitDoesNotExistError, EmptyRepositoryError)
50 49 from rhodecode.model import BaseModel
51 50 from rhodecode.model.changeset_status import ChangesetStatusModel
52 51 from rhodecode.model.comment import CommentsModel
53 52 from rhodecode.model.db import (
54 PullRequest, PullRequestReviewers, ChangesetStatus,
53 or_, PullRequest, PullRequestReviewers, ChangesetStatus,
55 54 PullRequestVersion, ChangesetComment, Repository)
56 55 from rhodecode.model.meta import Session
57 56 from rhodecode.model.notification import NotificationModel, \
58 57 EmailNotificationModel
59 58 from rhodecode.model.scm import ScmModel
60 59 from rhodecode.model.settings import VcsSettingsModel
61 60
62 61
63 62 log = logging.getLogger(__name__)
64 63
65 64
66 65 # Data structure to hold the response data when updating commits during a pull
67 66 # request update.
68 UpdateResponse = namedtuple('UpdateResponse', [
67 UpdateResponse = collections.namedtuple('UpdateResponse', [
69 68 'executed', 'reason', 'new', 'old', 'changes',
70 69 'source_changed', 'target_changed'])
71 70
72 71
73 72 class PullRequestModel(BaseModel):
74 73
75 74 cls = PullRequest
76 75
77 76 DIFF_CONTEXT = 3
78 77
79 78 MERGE_STATUS_MESSAGES = {
80 79 MergeFailureReason.NONE: lazy_ugettext(
81 80 'This pull request can be automatically merged.'),
82 81 MergeFailureReason.UNKNOWN: lazy_ugettext(
83 82 'This pull request cannot be merged because of an unhandled'
84 83 ' exception.'),
85 84 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
86 85 'This pull request cannot be merged because of merge conflicts.'),
87 86 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
88 87 'This pull request could not be merged because push to target'
89 88 ' failed.'),
90 89 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
91 90 'This pull request cannot be merged because the target is not a'
92 91 ' head.'),
93 92 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
94 93 'This pull request cannot be merged because the source contains'
95 94 ' more branches than the target.'),
96 95 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
97 96 'This pull request cannot be merged because the target has'
98 97 ' multiple heads.'),
99 98 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
100 99 'This pull request cannot be merged because the target repository'
101 100 ' is locked.'),
102 101 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
103 102 'This pull request cannot be merged because the target or the '
104 103 'source reference is missing.'),
105 104 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
106 105 'This pull request cannot be merged because the target '
107 106 'reference is missing.'),
108 107 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
109 108 'This pull request cannot be merged because the source '
110 109 'reference is missing.'),
111 110 MergeFailureReason.SUBREPO_MERGE_FAILED: lazy_ugettext(
112 111 'This pull request cannot be merged because of conflicts related '
113 112 'to sub repositories.'),
114 113 }
115 114
116 115 UPDATE_STATUS_MESSAGES = {
117 116 UpdateFailureReason.NONE: lazy_ugettext(
118 117 'Pull request update successful.'),
119 118 UpdateFailureReason.UNKNOWN: lazy_ugettext(
120 119 'Pull request update failed because of an unknown error.'),
121 120 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
122 121 'No update needed because the source and target have not changed.'),
123 122 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
124 123 'Pull request cannot be updated because the reference type is '
125 124 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
126 125 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
127 126 'This pull request cannot be updated because the target '
128 127 'reference is missing.'),
129 128 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
130 129 'This pull request cannot be updated because the source '
131 130 'reference is missing.'),
132 131 }
133 132
134 133 def __get_pull_request(self, pull_request):
135 134 return self._get_instance((
136 135 PullRequest, PullRequestVersion), pull_request)
137 136
138 137 def _check_perms(self, perms, pull_request, user, api=False):
139 138 if not api:
140 139 return h.HasRepoPermissionAny(*perms)(
141 140 user=user, repo_name=pull_request.target_repo.repo_name)
142 141 else:
143 142 return h.HasRepoPermissionAnyApi(*perms)(
144 143 user=user, repo_name=pull_request.target_repo.repo_name)
145 144
146 145 def check_user_read(self, pull_request, user, api=False):
147 146 _perms = ('repository.admin', 'repository.write', 'repository.read',)
148 147 return self._check_perms(_perms, pull_request, user, api)
149 148
150 149 def check_user_merge(self, pull_request, user, api=False):
151 150 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
152 151 return self._check_perms(_perms, pull_request, user, api)
153 152
154 153 def check_user_update(self, pull_request, user, api=False):
155 154 owner = user.user_id == pull_request.user_id
156 155 return self.check_user_merge(pull_request, user, api) or owner
157 156
158 157 def check_user_delete(self, pull_request, user):
159 158 owner = user.user_id == pull_request.user_id
160 159 _perms = ('repository.admin',)
161 160 return self._check_perms(_perms, pull_request, user) or owner
162 161
163 162 def check_user_change_status(self, pull_request, user, api=False):
164 163 reviewer = user.user_id in [x.user_id for x in
165 164 pull_request.reviewers]
166 165 return self.check_user_update(pull_request, user, api) or reviewer
167 166
168 167 def get(self, pull_request):
169 168 return self.__get_pull_request(pull_request)
170 169
171 170 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
172 171 opened_by=None, order_by=None,
173 172 order_dir='desc'):
174 173 repo = None
175 174 if repo_name:
176 175 repo = self._get_repo(repo_name)
177 176
178 177 q = PullRequest.query()
179 178
180 179 # source or target
181 180 if repo and source:
182 181 q = q.filter(PullRequest.source_repo == repo)
183 182 elif repo:
184 183 q = q.filter(PullRequest.target_repo == repo)
185 184
186 185 # closed,opened
187 186 if statuses:
188 187 q = q.filter(PullRequest.status.in_(statuses))
189 188
190 189 # opened by filter
191 190 if opened_by:
192 191 q = q.filter(PullRequest.user_id.in_(opened_by))
193 192
194 193 if order_by:
195 194 order_map = {
196 195 'name_raw': PullRequest.pull_request_id,
197 196 'title': PullRequest.title,
198 197 'updated_on_raw': PullRequest.updated_on,
199 198 'target_repo': PullRequest.target_repo_id
200 199 }
201 200 if order_dir == 'asc':
202 201 q = q.order_by(order_map[order_by].asc())
203 202 else:
204 203 q = q.order_by(order_map[order_by].desc())
205 204
206 205 return q
207 206
208 207 def count_all(self, repo_name, source=False, statuses=None,
209 208 opened_by=None):
210 209 """
211 210 Count the number of pull requests for a specific repository.
212 211
213 212 :param repo_name: target or source repo
214 213 :param source: boolean flag to specify if repo_name refers to source
215 214 :param statuses: list of pull request statuses
216 215 :param opened_by: author user of the pull request
217 216 :returns: int number of pull requests
218 217 """
219 218 q = self._prepare_get_all_query(
220 219 repo_name, source=source, statuses=statuses, opened_by=opened_by)
221 220
222 221 return q.count()
223 222
224 223 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
225 224 offset=0, length=None, order_by=None, order_dir='desc'):
226 225 """
227 226 Get all pull requests for a specific repository.
228 227
229 228 :param repo_name: target or source repo
230 229 :param source: boolean flag to specify if repo_name refers to source
231 230 :param statuses: list of pull request statuses
232 231 :param opened_by: author user of the pull request
233 232 :param offset: pagination offset
234 233 :param length: length of returned list
235 234 :param order_by: order of the returned list
236 235 :param order_dir: 'asc' or 'desc' ordering direction
237 236 :returns: list of pull requests
238 237 """
239 238 q = self._prepare_get_all_query(
240 239 repo_name, source=source, statuses=statuses, opened_by=opened_by,
241 240 order_by=order_by, order_dir=order_dir)
242 241
243 242 if length:
244 243 pull_requests = q.limit(length).offset(offset).all()
245 244 else:
246 245 pull_requests = q.all()
247 246
248 247 return pull_requests
249 248
250 249 def count_awaiting_review(self, repo_name, source=False, statuses=None,
251 250 opened_by=None):
252 251 """
253 252 Count the number of pull requests for a specific repository that are
254 253 awaiting review.
255 254
256 255 :param repo_name: target or source repo
257 256 :param source: boolean flag to specify if repo_name refers to source
258 257 :param statuses: list of pull request statuses
259 258 :param opened_by: author user of the pull request
260 259 :returns: int number of pull requests
261 260 """
262 261 pull_requests = self.get_awaiting_review(
263 262 repo_name, source=source, statuses=statuses, opened_by=opened_by)
264 263
265 264 return len(pull_requests)
266 265
267 266 def get_awaiting_review(self, repo_name, source=False, statuses=None,
268 267 opened_by=None, offset=0, length=None,
269 268 order_by=None, order_dir='desc'):
270 269 """
271 270 Get all pull requests for a specific repository that are awaiting
272 271 review.
273 272
274 273 :param repo_name: target or source repo
275 274 :param source: boolean flag to specify if repo_name refers to source
276 275 :param statuses: list of pull request statuses
277 276 :param opened_by: author user of the pull request
278 277 :param offset: pagination offset
279 278 :param length: length of returned list
280 279 :param order_by: order of the returned list
281 280 :param order_dir: 'asc' or 'desc' ordering direction
282 281 :returns: list of pull requests
283 282 """
284 283 pull_requests = self.get_all(
285 284 repo_name, source=source, statuses=statuses, opened_by=opened_by,
286 285 order_by=order_by, order_dir=order_dir)
287 286
288 287 _filtered_pull_requests = []
289 288 for pr in pull_requests:
290 289 status = pr.calculated_review_status()
291 290 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
292 291 ChangesetStatus.STATUS_UNDER_REVIEW]:
293 292 _filtered_pull_requests.append(pr)
294 293 if length:
295 294 return _filtered_pull_requests[offset:offset+length]
296 295 else:
297 296 return _filtered_pull_requests
298 297
299 298 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
300 299 opened_by=None, user_id=None):
301 300 """
302 301 Count the number of pull requests for a specific repository that are
303 302 awaiting review from a specific user.
304 303
305 304 :param repo_name: target or source repo
306 305 :param source: boolean flag to specify if repo_name refers to source
307 306 :param statuses: list of pull request statuses
308 307 :param opened_by: author user of the pull request
309 308 :param user_id: reviewer user of the pull request
310 309 :returns: int number of pull requests
311 310 """
312 311 pull_requests = self.get_awaiting_my_review(
313 312 repo_name, source=source, statuses=statuses, opened_by=opened_by,
314 313 user_id=user_id)
315 314
316 315 return len(pull_requests)
317 316
318 317 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
319 318 opened_by=None, user_id=None, offset=0,
320 319 length=None, order_by=None, order_dir='desc'):
321 320 """
322 321 Get all pull requests for a specific repository that are awaiting
323 322 review from a specific user.
324 323
325 324 :param repo_name: target or source repo
326 325 :param source: boolean flag to specify if repo_name refers to source
327 326 :param statuses: list of pull request statuses
328 327 :param opened_by: author user of the pull request
329 328 :param user_id: reviewer user of the pull request
330 329 :param offset: pagination offset
331 330 :param length: length of returned list
332 331 :param order_by: order of the returned list
333 332 :param order_dir: 'asc' or 'desc' ordering direction
334 333 :returns: list of pull requests
335 334 """
336 335 pull_requests = self.get_all(
337 336 repo_name, source=source, statuses=statuses, opened_by=opened_by,
338 337 order_by=order_by, order_dir=order_dir)
339 338
340 339 _my = PullRequestModel().get_not_reviewed(user_id)
341 340 my_participation = []
342 341 for pr in pull_requests:
343 342 if pr in _my:
344 343 my_participation.append(pr)
345 344 _filtered_pull_requests = my_participation
346 345 if length:
347 346 return _filtered_pull_requests[offset:offset+length]
348 347 else:
349 348 return _filtered_pull_requests
350 349
351 350 def get_not_reviewed(self, user_id):
352 351 return [
353 352 x.pull_request for x in PullRequestReviewers.query().filter(
354 353 PullRequestReviewers.user_id == user_id).all()
355 354 ]
356 355
357 356 def _prepare_participating_query(self, user_id=None, statuses=None,
358 357 order_by=None, order_dir='desc'):
359 358 q = PullRequest.query()
360 359 if user_id:
361 360 reviewers_subquery = Session().query(
362 361 PullRequestReviewers.pull_request_id).filter(
363 362 PullRequestReviewers.user_id == user_id).subquery()
364 user_filter= or_(
363 user_filter = or_(
365 364 PullRequest.user_id == user_id,
366 365 PullRequest.pull_request_id.in_(reviewers_subquery)
367 366 )
368 367 q = PullRequest.query().filter(user_filter)
369 368
370 369 # closed,opened
371 370 if statuses:
372 371 q = q.filter(PullRequest.status.in_(statuses))
373 372
374 373 if order_by:
375 374 order_map = {
376 375 'name_raw': PullRequest.pull_request_id,
377 376 'title': PullRequest.title,
378 377 'updated_on_raw': PullRequest.updated_on,
379 378 'target_repo': PullRequest.target_repo_id
380 379 }
381 380 if order_dir == 'asc':
382 381 q = q.order_by(order_map[order_by].asc())
383 382 else:
384 383 q = q.order_by(order_map[order_by].desc())
385 384
386 385 return q
387 386
388 387 def count_im_participating_in(self, user_id=None, statuses=None):
389 388 q = self._prepare_participating_query(user_id, statuses=statuses)
390 389 return q.count()
391 390
392 391 def get_im_participating_in(
393 392 self, user_id=None, statuses=None, offset=0,
394 393 length=None, order_by=None, order_dir='desc'):
395 394 """
396 395 Get all Pull requests that i'm participating in, or i have opened
397 396 """
398 397
399 398 q = self._prepare_participating_query(
400 399 user_id, statuses=statuses, order_by=order_by,
401 400 order_dir=order_dir)
402 401
403 402 if length:
404 403 pull_requests = q.limit(length).offset(offset).all()
405 404 else:
406 405 pull_requests = q.all()
407 406
408 407 return pull_requests
409 408
410 409 def get_versions(self, pull_request):
411 410 """
412 411 returns version of pull request sorted by ID descending
413 412 """
414 413 return PullRequestVersion.query()\
415 414 .filter(PullRequestVersion.pull_request == pull_request)\
416 415 .order_by(PullRequestVersion.pull_request_version_id.asc())\
417 416 .all()
418 417
419 418 def create(self, created_by, source_repo, source_ref, target_repo,
420 419 target_ref, revisions, reviewers, title, description=None,
421 reviewer_data=None):
420 reviewer_data=None, translator=None):
421 translator = translator or get_current_request().translate
422 422
423 423 created_by_user = self._get_user(created_by)
424 424 source_repo = self._get_repo(source_repo)
425 425 target_repo = self._get_repo(target_repo)
426 426
427 427 pull_request = PullRequest()
428 428 pull_request.source_repo = source_repo
429 429 pull_request.source_ref = source_ref
430 430 pull_request.target_repo = target_repo
431 431 pull_request.target_ref = target_ref
432 432 pull_request.revisions = revisions
433 433 pull_request.title = title
434 434 pull_request.description = description
435 435 pull_request.author = created_by_user
436 436 pull_request.reviewer_data = reviewer_data
437 437
438 438 Session().add(pull_request)
439 439 Session().flush()
440 440
441 441 reviewer_ids = set()
442 442 # members / reviewers
443 443 for reviewer_object in reviewers:
444 444 user_id, reasons, mandatory = reviewer_object
445 445 user = self._get_user(user_id)
446 446
447 447 # skip duplicates
448 448 if user.user_id in reviewer_ids:
449 449 continue
450 450
451 451 reviewer_ids.add(user.user_id)
452 452
453 453 reviewer = PullRequestReviewers()
454 454 reviewer.user = user
455 455 reviewer.pull_request = pull_request
456 456 reviewer.reasons = reasons
457 457 reviewer.mandatory = mandatory
458 458 Session().add(reviewer)
459 459
460 460 # Set approval status to "Under Review" for all commits which are
461 461 # part of this pull request.
462 462 ChangesetStatusModel().set_status(
463 463 repo=target_repo,
464 464 status=ChangesetStatus.STATUS_UNDER_REVIEW,
465 465 user=created_by_user,
466 466 pull_request=pull_request
467 467 )
468 468
469 MergeCheck.validate(
470 pull_request, user=created_by_user, translator=translator)
471
469 472 self.notify_reviewers(pull_request, reviewer_ids)
470 473 self._trigger_pull_request_hook(
471 474 pull_request, created_by_user, 'create')
472 475
473 476 creation_data = pull_request.get_api_data(with_merge_state=False)
474 477 self._log_audit_action(
475 478 'repo.pull_request.create', {'data': creation_data},
476 479 created_by_user, pull_request)
477 480
478 481 return pull_request
479 482
480 483 def _trigger_pull_request_hook(self, pull_request, user, action):
481 484 pull_request = self.__get_pull_request(pull_request)
482 485 target_scm = pull_request.target_repo.scm_instance()
483 486 if action == 'create':
484 487 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
485 488 elif action == 'merge':
486 489 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
487 490 elif action == 'close':
488 491 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
489 492 elif action == 'review_status_change':
490 493 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
491 494 elif action == 'update':
492 495 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
493 496 else:
494 497 return
495 498
496 499 trigger_hook(
497 500 username=user.username,
498 501 repo_name=pull_request.target_repo.repo_name,
499 502 repo_alias=target_scm.alias,
500 503 pull_request=pull_request)
501 504
502 505 def _get_commit_ids(self, pull_request):
503 506 """
504 507 Return the commit ids of the merged pull request.
505 508
506 509 This method is not dealing correctly yet with the lack of autoupdates
507 510 nor with the implicit target updates.
508 511 For example: if a commit in the source repo is already in the target it
509 512 will be reported anyways.
510 513 """
511 514 merge_rev = pull_request.merge_rev
512 515 if merge_rev is None:
513 516 raise ValueError('This pull request was not merged yet')
514 517
515 518 commit_ids = list(pull_request.revisions)
516 519 if merge_rev not in commit_ids:
517 520 commit_ids.append(merge_rev)
518 521
519 522 return commit_ids
520 523
521 524 def merge(self, pull_request, user, extras):
522 525 log.debug("Merging pull request %s", pull_request.pull_request_id)
523 526 merge_state = self._merge_pull_request(pull_request, user, extras)
524 527 if merge_state.executed:
525 528 log.debug(
526 529 "Merge was successful, updating the pull request comments.")
527 530 self._comment_and_close_pr(pull_request, user, merge_state)
528 531
529 532 self._log_audit_action(
530 533 'repo.pull_request.merge',
531 534 {'merge_state': merge_state.__dict__},
532 535 user, pull_request)
533 536
534 537 else:
535 538 log.warn("Merge failed, not updating the pull request.")
536 539 return merge_state
537 540
538 def _merge_pull_request(self, pull_request, user, extras):
541 def _merge_pull_request(self, pull_request, user, extras, merge_msg=None):
539 542 target_vcs = pull_request.target_repo.scm_instance()
540 543 source_vcs = pull_request.source_repo.scm_instance()
541 544 target_ref = self._refresh_reference(
542 545 pull_request.target_ref_parts, target_vcs)
543 546
544 message = _(
547 message = merge_msg or (
545 548 'Merge pull request #%(pr_id)s from '
546 549 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
547 550 'pr_id': pull_request.pull_request_id,
548 551 'source_repo': source_vcs.name,
549 552 'source_ref_name': pull_request.source_ref_parts.name,
550 553 'pr_title': pull_request.title
551 554 }
552 555
553 556 workspace_id = self._workspace_id(pull_request)
554 557 use_rebase = self._use_rebase_for_merging(pull_request)
555 558 close_branch = self._close_branch_before_merging(pull_request)
556 559
557 560 callback_daemon, extras = prepare_callback_daemon(
558 561 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
559 562 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
560 563
561 564 with callback_daemon:
562 565 # TODO: johbo: Implement a clean way to run a config_override
563 566 # for a single call.
564 567 target_vcs.config.set(
565 568 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
566 569 merge_state = target_vcs.merge(
567 570 target_ref, source_vcs, pull_request.source_ref_parts,
568 571 workspace_id, user_name=user.username,
569 572 user_email=user.email, message=message, use_rebase=use_rebase,
570 573 close_branch=close_branch)
571 574 return merge_state
572 575
573 def _comment_and_close_pr(self, pull_request, user, merge_state):
576 def _comment_and_close_pr(self, pull_request, user, merge_state, close_msg=None):
574 577 pull_request.merge_rev = merge_state.merge_ref.commit_id
575 578 pull_request.updated_on = datetime.datetime.now()
579 close_msg = close_msg or 'Pull request merged and closed'
576 580
577 581 CommentsModel().create(
578 text=unicode(_('Pull request merged and closed')),
582 text=safe_unicode(close_msg),
579 583 repo=pull_request.target_repo.repo_id,
580 584 user=user.user_id,
581 585 pull_request=pull_request.pull_request_id,
582 586 f_path=None,
583 587 line_no=None,
584 588 closing_pr=True
585 589 )
586 590
587 591 Session().add(pull_request)
588 592 Session().flush()
589 593 # TODO: paris: replace invalidation with less radical solution
590 594 ScmModel().mark_for_invalidation(
591 595 pull_request.target_repo.repo_name)
592 596 self._trigger_pull_request_hook(pull_request, user, 'merge')
593 597
594 598 def has_valid_update_type(self, pull_request):
595 599 source_ref_type = pull_request.source_ref_parts.type
596 600 return source_ref_type in ['book', 'branch', 'tag']
597 601
598 602 def update_commits(self, pull_request):
599 603 """
600 604 Get the updated list of commits for the pull request
601 605 and return the new pull request version and the list
602 606 of commits processed by this update action
603 607 """
604 608 pull_request = self.__get_pull_request(pull_request)
605 609 source_ref_type = pull_request.source_ref_parts.type
606 610 source_ref_name = pull_request.source_ref_parts.name
607 611 source_ref_id = pull_request.source_ref_parts.commit_id
608 612
609 613 target_ref_type = pull_request.target_ref_parts.type
610 614 target_ref_name = pull_request.target_ref_parts.name
611 615 target_ref_id = pull_request.target_ref_parts.commit_id
612 616
613 617 if not self.has_valid_update_type(pull_request):
614 618 log.debug(
615 619 "Skipping update of pull request %s due to ref type: %s",
616 620 pull_request, source_ref_type)
617 621 return UpdateResponse(
618 622 executed=False,
619 623 reason=UpdateFailureReason.WRONG_REF_TYPE,
620 624 old=pull_request, new=None, changes=None,
621 625 source_changed=False, target_changed=False)
622 626
623 627 # source repo
624 628 source_repo = pull_request.source_repo.scm_instance()
625 629 try:
626 630 source_commit = source_repo.get_commit(commit_id=source_ref_name)
627 631 except CommitDoesNotExistError:
628 632 return UpdateResponse(
629 633 executed=False,
630 634 reason=UpdateFailureReason.MISSING_SOURCE_REF,
631 635 old=pull_request, new=None, changes=None,
632 636 source_changed=False, target_changed=False)
633 637
634 638 source_changed = source_ref_id != source_commit.raw_id
635 639
636 640 # target repo
637 641 target_repo = pull_request.target_repo.scm_instance()
638 642 try:
639 643 target_commit = target_repo.get_commit(commit_id=target_ref_name)
640 644 except CommitDoesNotExistError:
641 645 return UpdateResponse(
642 646 executed=False,
643 647 reason=UpdateFailureReason.MISSING_TARGET_REF,
644 648 old=pull_request, new=None, changes=None,
645 649 source_changed=False, target_changed=False)
646 650 target_changed = target_ref_id != target_commit.raw_id
647 651
648 652 if not (source_changed or target_changed):
649 653 log.debug("Nothing changed in pull request %s", pull_request)
650 654 return UpdateResponse(
651 655 executed=False,
652 656 reason=UpdateFailureReason.NO_CHANGE,
653 657 old=pull_request, new=None, changes=None,
654 658 source_changed=target_changed, target_changed=source_changed)
655 659
656 660 change_in_found = 'target repo' if target_changed else 'source repo'
657 661 log.debug('Updating pull request because of change in %s detected',
658 662 change_in_found)
659 663
660 664 # Finally there is a need for an update, in case of source change
661 665 # we create a new version, else just an update
662 666 if source_changed:
663 667 pull_request_version = self._create_version_from_snapshot(pull_request)
664 668 self._link_comments_to_version(pull_request_version)
665 669 else:
666 670 try:
667 671 ver = pull_request.versions[-1]
668 672 except IndexError:
669 673 ver = None
670 674
671 675 pull_request.pull_request_version_id = \
672 676 ver.pull_request_version_id if ver else None
673 677 pull_request_version = pull_request
674 678
675 679 try:
676 680 if target_ref_type in ('tag', 'branch', 'book'):
677 681 target_commit = target_repo.get_commit(target_ref_name)
678 682 else:
679 683 target_commit = target_repo.get_commit(target_ref_id)
680 684 except CommitDoesNotExistError:
681 685 return UpdateResponse(
682 686 executed=False,
683 687 reason=UpdateFailureReason.MISSING_TARGET_REF,
684 688 old=pull_request, new=None, changes=None,
685 689 source_changed=source_changed, target_changed=target_changed)
686 690
687 691 # re-compute commit ids
688 692 old_commit_ids = pull_request.revisions
689 693 pre_load = ["author", "branch", "date", "message"]
690 694 commit_ranges = target_repo.compare(
691 695 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
692 696 pre_load=pre_load)
693 697
694 698 ancestor = target_repo.get_common_ancestor(
695 699 target_commit.raw_id, source_commit.raw_id, source_repo)
696 700
697 701 pull_request.source_ref = '%s:%s:%s' % (
698 702 source_ref_type, source_ref_name, source_commit.raw_id)
699 703 pull_request.target_ref = '%s:%s:%s' % (
700 704 target_ref_type, target_ref_name, ancestor)
701 705
702 706 pull_request.revisions = [
703 707 commit.raw_id for commit in reversed(commit_ranges)]
704 708 pull_request.updated_on = datetime.datetime.now()
705 709 Session().add(pull_request)
706 710 new_commit_ids = pull_request.revisions
707 711
708 712 old_diff_data, new_diff_data = self._generate_update_diffs(
709 713 pull_request, pull_request_version)
710 714
711 715 # calculate commit and file changes
712 716 changes = self._calculate_commit_id_changes(
713 717 old_commit_ids, new_commit_ids)
714 718 file_changes = self._calculate_file_changes(
715 719 old_diff_data, new_diff_data)
716 720
717 721 # set comments as outdated if DIFFS changed
718 722 CommentsModel().outdate_comments(
719 723 pull_request, old_diff_data=old_diff_data,
720 724 new_diff_data=new_diff_data)
721 725
722 726 commit_changes = (changes.added or changes.removed)
723 727 file_node_changes = (
724 728 file_changes.added or file_changes.modified or file_changes.removed)
725 729 pr_has_changes = commit_changes or file_node_changes
726 730
727 731 # Add an automatic comment to the pull request, in case
728 732 # anything has changed
729 733 if pr_has_changes:
730 734 update_comment = CommentsModel().create(
731 735 text=self._render_update_message(changes, file_changes),
732 736 repo=pull_request.target_repo,
733 737 user=pull_request.author,
734 738 pull_request=pull_request,
735 739 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
736 740
737 741 # Update status to "Under Review" for added commits
738 742 for commit_id in changes.added:
739 743 ChangesetStatusModel().set_status(
740 744 repo=pull_request.source_repo,
741 745 status=ChangesetStatus.STATUS_UNDER_REVIEW,
742 746 comment=update_comment,
743 747 user=pull_request.author,
744 748 pull_request=pull_request,
745 749 revision=commit_id)
746 750
747 751 log.debug(
748 752 'Updated pull request %s, added_ids: %s, common_ids: %s, '
749 753 'removed_ids: %s', pull_request.pull_request_id,
750 754 changes.added, changes.common, changes.removed)
751 755 log.debug(
752 756 'Updated pull request with the following file changes: %s',
753 757 file_changes)
754 758
755 759 log.info(
756 760 "Updated pull request %s from commit %s to commit %s, "
757 761 "stored new version %s of this pull request.",
758 762 pull_request.pull_request_id, source_ref_id,
759 763 pull_request.source_ref_parts.commit_id,
760 764 pull_request_version.pull_request_version_id)
761 765 Session().commit()
762 766 self._trigger_pull_request_hook(
763 767 pull_request, pull_request.author, 'update')
764 768
765 769 return UpdateResponse(
766 770 executed=True, reason=UpdateFailureReason.NONE,
767 771 old=pull_request, new=pull_request_version, changes=changes,
768 772 source_changed=source_changed, target_changed=target_changed)
769 773
770 774 def _create_version_from_snapshot(self, pull_request):
771 775 version = PullRequestVersion()
772 776 version.title = pull_request.title
773 777 version.description = pull_request.description
774 778 version.status = pull_request.status
775 779 version.created_on = datetime.datetime.now()
776 780 version.updated_on = pull_request.updated_on
777 781 version.user_id = pull_request.user_id
778 782 version.source_repo = pull_request.source_repo
779 783 version.source_ref = pull_request.source_ref
780 784 version.target_repo = pull_request.target_repo
781 785 version.target_ref = pull_request.target_ref
782 786
783 787 version._last_merge_source_rev = pull_request._last_merge_source_rev
784 788 version._last_merge_target_rev = pull_request._last_merge_target_rev
785 789 version.last_merge_status = pull_request.last_merge_status
786 790 version.shadow_merge_ref = pull_request.shadow_merge_ref
787 791 version.merge_rev = pull_request.merge_rev
788 792 version.reviewer_data = pull_request.reviewer_data
789 793
790 794 version.revisions = pull_request.revisions
791 795 version.pull_request = pull_request
792 796 Session().add(version)
793 797 Session().flush()
794 798
795 799 return version
796 800
797 801 def _generate_update_diffs(self, pull_request, pull_request_version):
798 802
799 803 diff_context = (
800 804 self.DIFF_CONTEXT +
801 805 CommentsModel.needed_extra_diff_context())
802 806
803 807 source_repo = pull_request_version.source_repo
804 808 source_ref_id = pull_request_version.source_ref_parts.commit_id
805 809 target_ref_id = pull_request_version.target_ref_parts.commit_id
806 810 old_diff = self._get_diff_from_pr_or_version(
807 811 source_repo, source_ref_id, target_ref_id, context=diff_context)
808 812
809 813 source_repo = pull_request.source_repo
810 814 source_ref_id = pull_request.source_ref_parts.commit_id
811 815 target_ref_id = pull_request.target_ref_parts.commit_id
812 816
813 817 new_diff = self._get_diff_from_pr_or_version(
814 818 source_repo, source_ref_id, target_ref_id, context=diff_context)
815 819
816 820 old_diff_data = diffs.DiffProcessor(old_diff)
817 821 old_diff_data.prepare()
818 822 new_diff_data = diffs.DiffProcessor(new_diff)
819 823 new_diff_data.prepare()
820 824
821 825 return old_diff_data, new_diff_data
822 826
823 827 def _link_comments_to_version(self, pull_request_version):
824 828 """
825 829 Link all unlinked comments of this pull request to the given version.
826 830
827 831 :param pull_request_version: The `PullRequestVersion` to which
828 832 the comments shall be linked.
829 833
830 834 """
831 835 pull_request = pull_request_version.pull_request
832 836 comments = ChangesetComment.query()\
833 837 .filter(
834 838 # TODO: johbo: Should we query for the repo at all here?
835 839 # Pending decision on how comments of PRs are to be related
836 840 # to either the source repo, the target repo or no repo at all.
837 841 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
838 842 ChangesetComment.pull_request == pull_request,
839 843 ChangesetComment.pull_request_version == None)\
840 844 .order_by(ChangesetComment.comment_id.asc())
841 845
842 846 # TODO: johbo: Find out why this breaks if it is done in a bulk
843 847 # operation.
844 848 for comment in comments:
845 849 comment.pull_request_version_id = (
846 850 pull_request_version.pull_request_version_id)
847 851 Session().add(comment)
848 852
849 853 def _calculate_commit_id_changes(self, old_ids, new_ids):
850 854 added = [x for x in new_ids if x not in old_ids]
851 855 common = [x for x in new_ids if x in old_ids]
852 856 removed = [x for x in old_ids if x not in new_ids]
853 857 total = new_ids
854 858 return ChangeTuple(added, common, removed, total)
855 859
856 860 def _calculate_file_changes(self, old_diff_data, new_diff_data):
857 861
858 862 old_files = OrderedDict()
859 863 for diff_data in old_diff_data.parsed_diff:
860 864 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
861 865
862 866 added_files = []
863 867 modified_files = []
864 868 removed_files = []
865 869 for diff_data in new_diff_data.parsed_diff:
866 870 new_filename = diff_data['filename']
867 871 new_hash = md5_safe(diff_data['raw_diff'])
868 872
869 873 old_hash = old_files.get(new_filename)
870 874 if not old_hash:
871 875 # file is not present in old diff, means it's added
872 876 added_files.append(new_filename)
873 877 else:
874 878 if new_hash != old_hash:
875 879 modified_files.append(new_filename)
876 880 # now remove a file from old, since we have seen it already
877 881 del old_files[new_filename]
878 882
879 883 # removed files is when there are present in old, but not in NEW,
880 884 # since we remove old files that are present in new diff, left-overs
881 885 # if any should be the removed files
882 886 removed_files.extend(old_files.keys())
883 887
884 888 return FileChangeTuple(added_files, modified_files, removed_files)
885 889
886 890 def _render_update_message(self, changes, file_changes):
887 891 """
888 892 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
889 893 so it's always looking the same disregarding on which default
890 894 renderer system is using.
891 895
892 896 :param changes: changes named tuple
893 897 :param file_changes: file changes named tuple
894 898
895 899 """
896 900 new_status = ChangesetStatus.get_status_lbl(
897 901 ChangesetStatus.STATUS_UNDER_REVIEW)
898 902
899 903 changed_files = (
900 904 file_changes.added + file_changes.modified + file_changes.removed)
901 905
902 906 params = {
903 907 'under_review_label': new_status,
904 908 'added_commits': changes.added,
905 909 'removed_commits': changes.removed,
906 910 'changed_files': changed_files,
907 911 'added_files': file_changes.added,
908 912 'modified_files': file_changes.modified,
909 913 'removed_files': file_changes.removed,
910 914 }
911 915 renderer = RstTemplateRenderer()
912 916 return renderer.render('pull_request_update.mako', **params)
913 917
914 918 def edit(self, pull_request, title, description, user):
915 919 pull_request = self.__get_pull_request(pull_request)
916 920 old_data = pull_request.get_api_data(with_merge_state=False)
917 921 if pull_request.is_closed():
918 922 raise ValueError('This pull request is closed')
919 923 if title:
920 924 pull_request.title = title
921 925 pull_request.description = description
922 926 pull_request.updated_on = datetime.datetime.now()
923 927 Session().add(pull_request)
924 928 self._log_audit_action(
925 929 'repo.pull_request.edit', {'old_data': old_data},
926 930 user, pull_request)
927 931
928 932 def update_reviewers(self, pull_request, reviewer_data, user):
929 933 """
930 934 Update the reviewers in the pull request
931 935
932 936 :param pull_request: the pr to update
933 937 :param reviewer_data: list of tuples
934 938 [(user, ['reason1', 'reason2'], mandatory_flag)]
935 939 """
936 940
937 941 reviewers = {}
938 942 for user_id, reasons, mandatory in reviewer_data:
939 943 if isinstance(user_id, (int, basestring)):
940 944 user_id = self._get_user(user_id).user_id
941 945 reviewers[user_id] = {
942 946 'reasons': reasons, 'mandatory': mandatory}
943 947
944 948 reviewers_ids = set(reviewers.keys())
945 949 pull_request = self.__get_pull_request(pull_request)
946 950 current_reviewers = PullRequestReviewers.query()\
947 951 .filter(PullRequestReviewers.pull_request ==
948 952 pull_request).all()
949 953 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
950 954
951 955 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
952 956 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
953 957
954 958 log.debug("Adding %s reviewers", ids_to_add)
955 959 log.debug("Removing %s reviewers", ids_to_remove)
956 960 changed = False
957 961 for uid in ids_to_add:
958 962 changed = True
959 963 _usr = self._get_user(uid)
960 964 reviewer = PullRequestReviewers()
961 965 reviewer.user = _usr
962 966 reviewer.pull_request = pull_request
963 967 reviewer.reasons = reviewers[uid]['reasons']
964 968 # NOTE(marcink): mandatory shouldn't be changed now
965 969 # reviewer.mandatory = reviewers[uid]['reasons']
966 970 Session().add(reviewer)
967 971 self._log_audit_action(
968 972 'repo.pull_request.reviewer.add', {'data': reviewer.get_dict()},
969 973 user, pull_request)
970 974
971 975 for uid in ids_to_remove:
972 976 changed = True
973 977 reviewers = PullRequestReviewers.query()\
974 978 .filter(PullRequestReviewers.user_id == uid,
975 979 PullRequestReviewers.pull_request == pull_request)\
976 980 .all()
977 981 # use .all() in case we accidentally added the same person twice
978 982 # this CAN happen due to the lack of DB checks
979 983 for obj in reviewers:
980 984 old_data = obj.get_dict()
981 985 Session().delete(obj)
982 986 self._log_audit_action(
983 987 'repo.pull_request.reviewer.delete',
984 988 {'old_data': old_data}, user, pull_request)
985 989
986 990 if changed:
987 991 pull_request.updated_on = datetime.datetime.now()
988 992 Session().add(pull_request)
989 993
990 994 self.notify_reviewers(pull_request, ids_to_add)
991 995 return ids_to_add, ids_to_remove
992 996
993 997 def get_url(self, pull_request, request=None, permalink=False):
994 998 if not request:
995 999 request = get_current_request()
996 1000
997 1001 if permalink:
998 1002 return request.route_url(
999 1003 'pull_requests_global',
1000 1004 pull_request_id=pull_request.pull_request_id,)
1001 1005 else:
1002 1006 return request.route_url('pullrequest_show',
1003 1007 repo_name=safe_str(pull_request.target_repo.repo_name),
1004 1008 pull_request_id=pull_request.pull_request_id,)
1005 1009
1006 1010 def get_shadow_clone_url(self, pull_request):
1007 1011 """
1008 1012 Returns qualified url pointing to the shadow repository. If this pull
1009 1013 request is closed there is no shadow repository and ``None`` will be
1010 1014 returned.
1011 1015 """
1012 1016 if pull_request.is_closed():
1013 1017 return None
1014 1018 else:
1015 1019 pr_url = urllib.unquote(self.get_url(pull_request))
1016 1020 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1017 1021
1018 1022 def notify_reviewers(self, pull_request, reviewers_ids):
1019 1023 # notification to reviewers
1020 1024 if not reviewers_ids:
1021 1025 return
1022 1026
1023 1027 pull_request_obj = pull_request
1024 1028 # get the current participants of this pull request
1025 1029 recipients = reviewers_ids
1026 1030 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1027 1031
1028 1032 pr_source_repo = pull_request_obj.source_repo
1029 1033 pr_target_repo = pull_request_obj.target_repo
1030 1034
1031 1035 pr_url = h.route_url('pullrequest_show',
1032 1036 repo_name=pr_target_repo.repo_name,
1033 1037 pull_request_id=pull_request_obj.pull_request_id,)
1034 1038
1035 1039 # set some variables for email notification
1036 1040 pr_target_repo_url = h.route_url(
1037 1041 'repo_summary', repo_name=pr_target_repo.repo_name)
1038 1042
1039 1043 pr_source_repo_url = h.route_url(
1040 1044 'repo_summary', repo_name=pr_source_repo.repo_name)
1041 1045
1042 1046 # pull request specifics
1043 1047 pull_request_commits = [
1044 1048 (x.raw_id, x.message)
1045 1049 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1046 1050
1047 1051 kwargs = {
1048 1052 'user': pull_request.author,
1049 1053 'pull_request': pull_request_obj,
1050 1054 'pull_request_commits': pull_request_commits,
1051 1055
1052 1056 'pull_request_target_repo': pr_target_repo,
1053 1057 'pull_request_target_repo_url': pr_target_repo_url,
1054 1058
1055 1059 'pull_request_source_repo': pr_source_repo,
1056 1060 'pull_request_source_repo_url': pr_source_repo_url,
1057 1061
1058 1062 'pull_request_url': pr_url,
1059 1063 }
1060 1064
1061 1065 # pre-generate the subject for notification itself
1062 1066 (subject,
1063 1067 _h, _e, # we don't care about those
1064 1068 body_plaintext) = EmailNotificationModel().render_email(
1065 1069 notification_type, **kwargs)
1066 1070
1067 1071 # create notification objects, and emails
1068 1072 NotificationModel().create(
1069 1073 created_by=pull_request.author,
1070 1074 notification_subject=subject,
1071 1075 notification_body=body_plaintext,
1072 1076 notification_type=notification_type,
1073 1077 recipients=recipients,
1074 1078 email_kwargs=kwargs,
1075 1079 )
1076 1080
1077 1081 def delete(self, pull_request, user):
1078 1082 pull_request = self.__get_pull_request(pull_request)
1079 1083 old_data = pull_request.get_api_data(with_merge_state=False)
1080 1084 self._cleanup_merge_workspace(pull_request)
1081 1085 self._log_audit_action(
1082 1086 'repo.pull_request.delete', {'old_data': old_data},
1083 1087 user, pull_request)
1084 1088 Session().delete(pull_request)
1085 1089
1086 1090 def close_pull_request(self, pull_request, user):
1087 1091 pull_request = self.__get_pull_request(pull_request)
1088 1092 self._cleanup_merge_workspace(pull_request)
1089 1093 pull_request.status = PullRequest.STATUS_CLOSED
1090 1094 pull_request.updated_on = datetime.datetime.now()
1091 1095 Session().add(pull_request)
1092 1096 self._trigger_pull_request_hook(
1093 1097 pull_request, pull_request.author, 'close')
1094 1098
1095 1099 pr_data = pull_request.get_api_data(with_merge_state=False)
1096 1100 self._log_audit_action(
1097 1101 'repo.pull_request.close', {'data': pr_data}, user, pull_request)
1098 1102
1099 1103 def close_pull_request_with_comment(
1100 1104 self, pull_request, user, repo, message=None):
1101 1105
1102 1106 pull_request_review_status = pull_request.calculated_review_status()
1103 1107
1104 1108 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1105 1109 # approved only if we have voting consent
1106 1110 status = ChangesetStatus.STATUS_APPROVED
1107 1111 else:
1108 1112 status = ChangesetStatus.STATUS_REJECTED
1109 1113 status_lbl = ChangesetStatus.get_status_lbl(status)
1110 1114
1111 1115 default_message = (
1112 _('Closing with status change {transition_icon} {status}.')
1116 'Closing with status change {transition_icon} {status}.'
1113 1117 ).format(transition_icon='>', status=status_lbl)
1114 1118 text = message or default_message
1115 1119
1116 1120 # create a comment, and link it to new status
1117 1121 comment = CommentsModel().create(
1118 1122 text=text,
1119 1123 repo=repo.repo_id,
1120 1124 user=user.user_id,
1121 1125 pull_request=pull_request.pull_request_id,
1122 1126 status_change=status_lbl,
1123 1127 status_change_type=status,
1124 1128 closing_pr=True
1125 1129 )
1126 1130
1127 1131 # calculate old status before we change it
1128 1132 old_calculated_status = pull_request.calculated_review_status()
1129 1133 ChangesetStatusModel().set_status(
1130 1134 repo.repo_id,
1131 1135 status,
1132 1136 user.user_id,
1133 1137 comment=comment,
1134 1138 pull_request=pull_request.pull_request_id
1135 1139 )
1136 1140
1137 1141 Session().flush()
1138 1142 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1139 1143 # we now calculate the status of pull request again, and based on that
1140 1144 # calculation trigger status change. This might happen in cases
1141 1145 # that non-reviewer admin closes a pr, which means his vote doesn't
1142 1146 # change the status, while if he's a reviewer this might change it.
1143 1147 calculated_status = pull_request.calculated_review_status()
1144 1148 if old_calculated_status != calculated_status:
1145 1149 self._trigger_pull_request_hook(
1146 1150 pull_request, user, 'review_status_change')
1147 1151
1148 1152 # finally close the PR
1149 1153 PullRequestModel().close_pull_request(
1150 1154 pull_request.pull_request_id, user)
1151 1155
1152 1156 return comment, status
1153 1157
1154 def merge_status(self, pull_request):
1158 def merge_status(self, pull_request, translator=None):
1159 _ = translator or get_current_request().translate
1160
1155 1161 if not self._is_merge_enabled(pull_request):
1156 1162 return False, _('Server-side pull request merging is disabled.')
1157 1163 if pull_request.is_closed():
1158 1164 return False, _('This pull request is closed.')
1159 1165 merge_possible, msg = self._check_repo_requirements(
1160 target=pull_request.target_repo, source=pull_request.source_repo)
1166 target=pull_request.target_repo, source=pull_request.source_repo,
1167 translator=_)
1161 1168 if not merge_possible:
1162 1169 return merge_possible, msg
1163 1170
1164 1171 try:
1165 1172 resp = self._try_merge(pull_request)
1166 1173 log.debug("Merge response: %s", resp)
1167 1174 status = resp.possible, self.merge_status_message(
1168 1175 resp.failure_reason)
1169 1176 except NotImplementedError:
1170 1177 status = False, _('Pull request merging is not supported.')
1171 1178
1172 1179 return status
1173 1180
1174 def _check_repo_requirements(self, target, source):
1181 def _check_repo_requirements(self, target, source, translator):
1175 1182 """
1176 1183 Check if `target` and `source` have compatible requirements.
1177 1184
1178 1185 Currently this is just checking for largefiles.
1179 1186 """
1187 _ = translator
1180 1188 target_has_largefiles = self._has_largefiles(target)
1181 1189 source_has_largefiles = self._has_largefiles(source)
1182 1190 merge_possible = True
1183 1191 message = u''
1184 1192
1185 1193 if target_has_largefiles != source_has_largefiles:
1186 1194 merge_possible = False
1187 1195 if source_has_largefiles:
1188 1196 message = _(
1189 1197 'Target repository large files support is disabled.')
1190 1198 else:
1191 1199 message = _(
1192 1200 'Source repository large files support is disabled.')
1193 1201
1194 1202 return merge_possible, message
1195 1203
1196 1204 def _has_largefiles(self, repo):
1197 1205 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1198 1206 'extensions', 'largefiles')
1199 1207 return largefiles_ui and largefiles_ui[0].active
1200 1208
1201 1209 def _try_merge(self, pull_request):
1202 1210 """
1203 1211 Try to merge the pull request and return the merge status.
1204 1212 """
1205 1213 log.debug(
1206 1214 "Trying out if the pull request %s can be merged.",
1207 1215 pull_request.pull_request_id)
1208 1216 target_vcs = pull_request.target_repo.scm_instance()
1209 1217
1210 1218 # Refresh the target reference.
1211 1219 try:
1212 1220 target_ref = self._refresh_reference(
1213 1221 pull_request.target_ref_parts, target_vcs)
1214 1222 except CommitDoesNotExistError:
1215 1223 merge_state = MergeResponse(
1216 1224 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
1217 1225 return merge_state
1218 1226
1219 1227 target_locked = pull_request.target_repo.locked
1220 1228 if target_locked and target_locked[0]:
1221 1229 log.debug("The target repository is locked.")
1222 1230 merge_state = MergeResponse(
1223 1231 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
1224 1232 elif self._needs_merge_state_refresh(pull_request, target_ref):
1225 1233 log.debug("Refreshing the merge status of the repository.")
1226 1234 merge_state = self._refresh_merge_state(
1227 1235 pull_request, target_vcs, target_ref)
1228 1236 else:
1229 1237 possible = pull_request.\
1230 1238 last_merge_status == MergeFailureReason.NONE
1231 1239 merge_state = MergeResponse(
1232 1240 possible, False, None, pull_request.last_merge_status)
1233 1241
1234 1242 return merge_state
1235 1243
1236 1244 def _refresh_reference(self, reference, vcs_repository):
1237 1245 if reference.type in ('branch', 'book'):
1238 1246 name_or_id = reference.name
1239 1247 else:
1240 1248 name_or_id = reference.commit_id
1241 1249 refreshed_commit = vcs_repository.get_commit(name_or_id)
1242 1250 refreshed_reference = Reference(
1243 1251 reference.type, reference.name, refreshed_commit.raw_id)
1244 1252 return refreshed_reference
1245 1253
1246 1254 def _needs_merge_state_refresh(self, pull_request, target_reference):
1247 1255 return not(
1248 1256 pull_request.revisions and
1249 1257 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1250 1258 target_reference.commit_id == pull_request._last_merge_target_rev)
1251 1259
1252 1260 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1253 1261 workspace_id = self._workspace_id(pull_request)
1254 1262 source_vcs = pull_request.source_repo.scm_instance()
1255 1263 use_rebase = self._use_rebase_for_merging(pull_request)
1256 1264 close_branch = self._close_branch_before_merging(pull_request)
1257 1265 merge_state = target_vcs.merge(
1258 1266 target_reference, source_vcs, pull_request.source_ref_parts,
1259 1267 workspace_id, dry_run=True, use_rebase=use_rebase,
1260 1268 close_branch=close_branch)
1261 1269
1262 1270 # Do not store the response if there was an unknown error.
1263 1271 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1264 1272 pull_request._last_merge_source_rev = \
1265 1273 pull_request.source_ref_parts.commit_id
1266 1274 pull_request._last_merge_target_rev = target_reference.commit_id
1267 1275 pull_request.last_merge_status = merge_state.failure_reason
1268 1276 pull_request.shadow_merge_ref = merge_state.merge_ref
1269 1277 Session().add(pull_request)
1270 1278 Session().commit()
1271 1279
1272 1280 return merge_state
1273 1281
1274 1282 def _workspace_id(self, pull_request):
1275 1283 workspace_id = 'pr-%s' % pull_request.pull_request_id
1276 1284 return workspace_id
1277 1285
1278 1286 def merge_status_message(self, status_code):
1279 1287 """
1280 1288 Return a human friendly error message for the given merge status code.
1281 1289 """
1282 1290 return self.MERGE_STATUS_MESSAGES[status_code]
1283 1291
1284 1292 def generate_repo_data(self, repo, commit_id=None, branch=None,
1285 bookmark=None):
1293 bookmark=None, translator=None):
1294
1286 1295 all_refs, selected_ref = \
1287 1296 self._get_repo_pullrequest_sources(
1288 1297 repo.scm_instance(), commit_id=commit_id,
1289 branch=branch, bookmark=bookmark)
1298 branch=branch, bookmark=bookmark, translator=translator)
1290 1299
1291 1300 refs_select2 = []
1292 1301 for element in all_refs:
1293 1302 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1294 1303 refs_select2.append({'text': element[1], 'children': children})
1295 1304
1296 1305 return {
1297 1306 'user': {
1298 1307 'user_id': repo.user.user_id,
1299 1308 'username': repo.user.username,
1300 1309 'firstname': repo.user.first_name,
1301 1310 'lastname': repo.user.last_name,
1302 1311 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1303 1312 },
1304 1313 'description': h.chop_at_smart(repo.description_safe, '\n'),
1305 1314 'refs': {
1306 1315 'all_refs': all_refs,
1307 1316 'selected_ref': selected_ref,
1308 1317 'select2_refs': refs_select2
1309 1318 }
1310 1319 }
1311 1320
1312 1321 def generate_pullrequest_title(self, source, source_ref, target):
1313 1322 return u'{source}#{at_ref} to {target}'.format(
1314 1323 source=source,
1315 1324 at_ref=source_ref,
1316 1325 target=target,
1317 1326 )
1318 1327
1319 1328 def _cleanup_merge_workspace(self, pull_request):
1320 1329 # Merging related cleanup
1321 1330 target_scm = pull_request.target_repo.scm_instance()
1322 1331 workspace_id = 'pr-%s' % pull_request.pull_request_id
1323 1332
1324 1333 try:
1325 1334 target_scm.cleanup_merge_workspace(workspace_id)
1326 1335 except NotImplementedError:
1327 1336 pass
1328 1337
1329 1338 def _get_repo_pullrequest_sources(
1330 self, repo, commit_id=None, branch=None, bookmark=None):
1339 self, repo, commit_id=None, branch=None, bookmark=None,
1340 translator=None):
1331 1341 """
1332 1342 Return a structure with repo's interesting commits, suitable for
1333 1343 the selectors in pullrequest controller
1334 1344
1335 1345 :param commit_id: a commit that must be in the list somehow
1336 1346 and selected by default
1337 1347 :param branch: a branch that must be in the list and selected
1338 1348 by default - even if closed
1339 1349 :param bookmark: a bookmark that must be in the list and selected
1340 1350 """
1351 _ = translator or get_current_request().translate
1341 1352
1342 1353 commit_id = safe_str(commit_id) if commit_id else None
1343 1354 branch = safe_str(branch) if branch else None
1344 1355 bookmark = safe_str(bookmark) if bookmark else None
1345 1356
1346 1357 selected = None
1347 1358
1348 1359 # order matters: first source that has commit_id in it will be selected
1349 1360 sources = []
1350 1361 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1351 1362 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1352 1363
1353 1364 if commit_id:
1354 1365 ref_commit = (h.short_id(commit_id), commit_id)
1355 1366 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1356 1367
1357 1368 sources.append(
1358 1369 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1359 1370 )
1360 1371
1361 1372 groups = []
1362 1373 for group_key, ref_list, group_name, match in sources:
1363 1374 group_refs = []
1364 1375 for ref_name, ref_id in ref_list:
1365 1376 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1366 1377 group_refs.append((ref_key, ref_name))
1367 1378
1368 1379 if not selected:
1369 1380 if set([commit_id, match]) & set([ref_id, ref_name]):
1370 1381 selected = ref_key
1371 1382
1372 1383 if group_refs:
1373 1384 groups.append((group_refs, group_name))
1374 1385
1375 1386 if not selected:
1376 1387 ref = commit_id or branch or bookmark
1377 1388 if ref:
1378 1389 raise CommitDoesNotExistError(
1379 1390 'No commit refs could be found matching: %s' % ref)
1380 1391 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1381 1392 selected = 'branch:%s:%s' % (
1382 1393 repo.DEFAULT_BRANCH_NAME,
1383 1394 repo.branches[repo.DEFAULT_BRANCH_NAME]
1384 1395 )
1385 1396 elif repo.commit_ids:
1386 1397 rev = repo.commit_ids[0]
1387 1398 selected = 'rev:%s:%s' % (rev, rev)
1388 1399 else:
1389 1400 raise EmptyRepositoryError()
1390 1401 return groups, selected
1391 1402
1392 1403 def get_diff(self, source_repo, source_ref_id, target_ref_id, context=DIFF_CONTEXT):
1393 1404 return self._get_diff_from_pr_or_version(
1394 1405 source_repo, source_ref_id, target_ref_id, context=context)
1395 1406
1396 1407 def _get_diff_from_pr_or_version(
1397 1408 self, source_repo, source_ref_id, target_ref_id, context):
1398 1409 target_commit = source_repo.get_commit(
1399 1410 commit_id=safe_str(target_ref_id))
1400 1411 source_commit = source_repo.get_commit(
1401 1412 commit_id=safe_str(source_ref_id))
1402 1413 if isinstance(source_repo, Repository):
1403 1414 vcs_repo = source_repo.scm_instance()
1404 1415 else:
1405 1416 vcs_repo = source_repo
1406 1417
1407 1418 # TODO: johbo: In the context of an update, we cannot reach
1408 1419 # the old commit anymore with our normal mechanisms. It needs
1409 1420 # some sort of special support in the vcs layer to avoid this
1410 1421 # workaround.
1411 1422 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1412 1423 vcs_repo.alias == 'git'):
1413 1424 source_commit.raw_id = safe_str(source_ref_id)
1414 1425
1415 1426 log.debug('calculating diff between '
1416 1427 'source_ref:%s and target_ref:%s for repo `%s`',
1417 1428 target_ref_id, source_ref_id,
1418 1429 safe_unicode(vcs_repo.path))
1419 1430
1420 1431 vcs_diff = vcs_repo.get_diff(
1421 1432 commit1=target_commit, commit2=source_commit, context=context)
1422 1433 return vcs_diff
1423 1434
1424 1435 def _is_merge_enabled(self, pull_request):
1425 1436 return self._get_general_setting(
1426 1437 pull_request, 'rhodecode_pr_merge_enabled')
1427 1438
1428 1439 def _use_rebase_for_merging(self, pull_request):
1429 1440 repo_type = pull_request.target_repo.repo_type
1430 1441 if repo_type == 'hg':
1431 1442 return self._get_general_setting(
1432 1443 pull_request, 'rhodecode_hg_use_rebase_for_merging')
1433 1444 elif repo_type == 'git':
1434 1445 return self._get_general_setting(
1435 1446 pull_request, 'rhodecode_git_use_rebase_for_merging')
1436 1447
1437 1448 return False
1438 1449
1439 1450 def _close_branch_before_merging(self, pull_request):
1440 1451 repo_type = pull_request.target_repo.repo_type
1441 1452 if repo_type == 'hg':
1442 1453 return self._get_general_setting(
1443 1454 pull_request, 'rhodecode_hg_close_branch_before_merging')
1444 1455 elif repo_type == 'git':
1445 1456 return self._get_general_setting(
1446 1457 pull_request, 'rhodecode_git_close_branch_before_merging')
1447 1458
1448 1459 return False
1449 1460
1450 1461 def _get_general_setting(self, pull_request, settings_key, default=False):
1451 1462 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1452 1463 settings = settings_model.get_general_settings()
1453 1464 return settings.get(settings_key, default)
1454 1465
1455 1466 def _log_audit_action(self, action, action_data, user, pull_request):
1456 1467 audit_logger.store(
1457 1468 action=action,
1458 1469 action_data=action_data,
1459 1470 user=user,
1460 1471 repo=pull_request.target_repo)
1461 1472
1462 1473 def get_reviewer_functions(self):
1463 1474 """
1464 1475 Fetches functions for validation and fetching default reviewers.
1465 1476 If available we use the EE package, else we fallback to CE
1466 1477 package functions
1467 1478 """
1468 1479 try:
1469 1480 from rc_reviewers.utils import get_default_reviewers_data
1470 1481 from rc_reviewers.utils import validate_default_reviewers
1471 1482 except ImportError:
1472 1483 from rhodecode.apps.repository.utils import \
1473 1484 get_default_reviewers_data
1474 1485 from rhodecode.apps.repository.utils import \
1475 1486 validate_default_reviewers
1476 1487
1477 1488 return get_default_reviewers_data, validate_default_reviewers
1478 1489
1479 1490
1480 1491 class MergeCheck(object):
1481 1492 """
1482 1493 Perform Merge Checks and returns a check object which stores information
1483 1494 about merge errors, and merge conditions
1484 1495 """
1485 1496 TODO_CHECK = 'todo'
1486 1497 PERM_CHECK = 'perm'
1487 1498 REVIEW_CHECK = 'review'
1488 1499 MERGE_CHECK = 'merge'
1489 1500
1490 1501 def __init__(self):
1491 1502 self.review_status = None
1492 1503 self.merge_possible = None
1493 1504 self.merge_msg = ''
1494 1505 self.failed = None
1495 1506 self.errors = []
1496 1507 self.error_details = OrderedDict()
1497 1508
1498 1509 def push_error(self, error_type, message, error_key, details):
1499 1510 self.failed = True
1500 1511 self.errors.append([error_type, message])
1501 1512 self.error_details[error_key] = dict(
1502 1513 details=details,
1503 1514 error_type=error_type,
1504 1515 message=message
1505 1516 )
1506 1517
1507 1518 @classmethod
1508 def validate(cls, pull_request, user, fail_early=False, translator=None):
1509 # if migrated to pyramid...
1510 # _ = lambda: translator or _ # use passed in translator if any
1511
1519 def validate(cls, pull_request, user, translator, fail_early=False):
1520 _ = translator
1512 1521 merge_check = cls()
1513 1522
1514 1523 # permissions to merge
1515 1524 user_allowed_to_merge = PullRequestModel().check_user_merge(
1516 1525 pull_request, user)
1517 1526 if not user_allowed_to_merge:
1518 1527 log.debug("MergeCheck: cannot merge, approval is pending.")
1519 1528
1520 1529 msg = _('User `{}` not allowed to perform merge.').format(user.username)
1521 1530 merge_check.push_error('error', msg, cls.PERM_CHECK, user.username)
1522 1531 if fail_early:
1523 1532 return merge_check
1524 1533
1525 1534 # review status, must be always present
1526 1535 review_status = pull_request.calculated_review_status()
1527 1536 merge_check.review_status = review_status
1528 1537
1529 1538 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1530 1539 if not status_approved:
1531 1540 log.debug("MergeCheck: cannot merge, approval is pending.")
1532 1541
1533 1542 msg = _('Pull request reviewer approval is pending.')
1534 1543
1535 1544 merge_check.push_error(
1536 1545 'warning', msg, cls.REVIEW_CHECK, review_status)
1537 1546
1538 1547 if fail_early:
1539 1548 return merge_check
1540 1549
1541 1550 # left over TODOs
1542 1551 todos = CommentsModel().get_unresolved_todos(pull_request)
1543 1552 if todos:
1544 1553 log.debug("MergeCheck: cannot merge, {} "
1545 1554 "unresolved todos left.".format(len(todos)))
1546 1555
1547 1556 if len(todos) == 1:
1548 1557 msg = _('Cannot merge, {} TODO still not resolved.').format(
1549 1558 len(todos))
1550 1559 else:
1551 1560 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1552 1561 len(todos))
1553 1562
1554 1563 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1555 1564
1556 1565 if fail_early:
1557 1566 return merge_check
1558 1567
1559 1568 # merge possible
1560 merge_status, msg = PullRequestModel().merge_status(pull_request)
1569 merge_status, msg = PullRequestModel().merge_status(
1570 pull_request, translator=translator)
1561 1571 merge_check.merge_possible = merge_status
1562 1572 merge_check.merge_msg = msg
1563 1573 if not merge_status:
1564 1574 log.debug(
1565 1575 "MergeCheck: cannot merge, pull request merge not possible.")
1566 1576 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1567 1577
1568 1578 if fail_early:
1569 1579 return merge_check
1570 1580
1571 1581 log.debug('MergeCheck: is failed: %s', merge_check.failed)
1572 1582 return merge_check
1573 1583
1574 1584 @classmethod
1575 def get_merge_conditions(cls, pull_request):
1585 def get_merge_conditions(cls, pull_request, translator):
1586 _ = translator
1576 1587 merge_details = {}
1577 1588
1578 1589 model = PullRequestModel()
1579 1590 use_rebase = model._use_rebase_for_merging(pull_request)
1580 1591
1581 1592 if use_rebase:
1582 1593 merge_details['merge_strategy'] = dict(
1583 1594 details={},
1584 1595 message=_('Merge strategy: rebase')
1585 1596 )
1586 1597 else:
1587 1598 merge_details['merge_strategy'] = dict(
1588 1599 details={},
1589 1600 message=_('Merge strategy: explicit merge commit')
1590 1601 )
1591 1602
1592 1603 close_branch = model._close_branch_before_merging(pull_request)
1593 1604 if close_branch:
1594 1605 repo_type = pull_request.target_repo.repo_type
1595 1606 if repo_type == 'hg':
1596 1607 close_msg = _('Source branch will be closed after merge.')
1597 1608 elif repo_type == 'git':
1598 1609 close_msg = _('Source branch will be deleted after merge.')
1599 1610
1600 1611 merge_details['close_branch'] = dict(
1601 1612 details={},
1602 1613 message=close_msg
1603 1614 )
1604 1615
1605 1616 return merge_details
1606 1617
1607 ChangeTuple = namedtuple('ChangeTuple',
1608 ['added', 'common', 'removed', 'total'])
1618 ChangeTuple = collections.namedtuple(
1619 'ChangeTuple', ['added', 'common', 'removed', 'total'])
1609 1620
1610 FileChangeTuple = namedtuple('FileChangeTuple',
1611 ['added', 'modified', 'removed'])
1621 FileChangeTuple = collections.namedtuple(
1622 'FileChangeTuple', ['added', 'modified', 'removed'])
@@ -1,859 +1,859 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import mock
22 22 import pytest
23 23 import textwrap
24 24
25 25 import rhodecode
26 26 from rhodecode.lib.utils2 import safe_unicode
27 27 from rhodecode.lib.vcs.backends import get_backend
28 28 from rhodecode.lib.vcs.backends.base import (
29 29 MergeResponse, MergeFailureReason, Reference)
30 30 from rhodecode.lib.vcs.exceptions import RepositoryError
31 31 from rhodecode.lib.vcs.nodes import FileNode
32 32 from rhodecode.model.comment import CommentsModel
33 33 from rhodecode.model.db import PullRequest, Session
34 34 from rhodecode.model.pull_request import PullRequestModel
35 35 from rhodecode.model.user import UserModel
36 36 from rhodecode.tests import TEST_USER_ADMIN_LOGIN
37 37
38 38
39 39 pytestmark = [
40 40 pytest.mark.backends("git", "hg"),
41 41 ]
42 42
43 43
44 44 @pytest.mark.usefixtures('config_stub')
45 45 class TestPullRequestModel(object):
46 46
47 47 @pytest.fixture
48 48 def pull_request(self, request, backend, pr_util):
49 49 """
50 50 A pull request combined with multiples patches.
51 51 """
52 52 BackendClass = get_backend(backend.alias)
53 53 self.merge_patcher = mock.patch.object(
54 54 BackendClass, 'merge', return_value=MergeResponse(
55 55 False, False, None, MergeFailureReason.UNKNOWN))
56 56 self.workspace_remove_patcher = mock.patch.object(
57 57 BackendClass, 'cleanup_merge_workspace')
58 58
59 59 self.workspace_remove_mock = self.workspace_remove_patcher.start()
60 60 self.merge_mock = self.merge_patcher.start()
61 61 self.comment_patcher = mock.patch(
62 62 'rhodecode.model.changeset_status.ChangesetStatusModel.set_status')
63 63 self.comment_patcher.start()
64 64 self.notification_patcher = mock.patch(
65 65 'rhodecode.model.notification.NotificationModel.create')
66 66 self.notification_patcher.start()
67 67 self.helper_patcher = mock.patch(
68 68 'rhodecode.lib.helpers.url')
69 69 self.helper_patcher.start()
70 70
71 71 self.hook_patcher = mock.patch.object(PullRequestModel,
72 72 '_trigger_pull_request_hook')
73 73 self.hook_mock = self.hook_patcher.start()
74 74
75 75 self.invalidation_patcher = mock.patch(
76 76 'rhodecode.model.pull_request.ScmModel.mark_for_invalidation')
77 77 self.invalidation_mock = self.invalidation_patcher.start()
78 78
79 79 self.pull_request = pr_util.create_pull_request(
80 80 mergeable=True, name_suffix=u'Δ…Δ‡')
81 81 self.source_commit = self.pull_request.source_ref_parts.commit_id
82 82 self.target_commit = self.pull_request.target_ref_parts.commit_id
83 83 self.workspace_id = 'pr-%s' % self.pull_request.pull_request_id
84 84
85 85 @request.addfinalizer
86 86 def cleanup_pull_request():
87 87 calls = [mock.call(
88 88 self.pull_request, self.pull_request.author, 'create')]
89 89 self.hook_mock.assert_has_calls(calls)
90 90
91 91 self.workspace_remove_patcher.stop()
92 92 self.merge_patcher.stop()
93 93 self.comment_patcher.stop()
94 94 self.notification_patcher.stop()
95 95 self.helper_patcher.stop()
96 96 self.hook_patcher.stop()
97 97 self.invalidation_patcher.stop()
98 98
99 99 return self.pull_request
100 100
101 101 def test_get_all(self, pull_request):
102 102 prs = PullRequestModel().get_all(pull_request.target_repo)
103 103 assert isinstance(prs, list)
104 104 assert len(prs) == 1
105 105
106 106 def test_count_all(self, pull_request):
107 107 pr_count = PullRequestModel().count_all(pull_request.target_repo)
108 108 assert pr_count == 1
109 109
110 110 def test_get_awaiting_review(self, pull_request):
111 111 prs = PullRequestModel().get_awaiting_review(pull_request.target_repo)
112 112 assert isinstance(prs, list)
113 113 assert len(prs) == 1
114 114
115 115 def test_count_awaiting_review(self, pull_request):
116 116 pr_count = PullRequestModel().count_awaiting_review(
117 117 pull_request.target_repo)
118 118 assert pr_count == 1
119 119
120 120 def test_get_awaiting_my_review(self, pull_request):
121 121 PullRequestModel().update_reviewers(
122 122 pull_request, [(pull_request.author, ['author'], False)],
123 123 pull_request.author)
124 124 prs = PullRequestModel().get_awaiting_my_review(
125 125 pull_request.target_repo, user_id=pull_request.author.user_id)
126 126 assert isinstance(prs, list)
127 127 assert len(prs) == 1
128 128
129 129 def test_count_awaiting_my_review(self, pull_request):
130 130 PullRequestModel().update_reviewers(
131 131 pull_request, [(pull_request.author, ['author'], False)],
132 132 pull_request.author)
133 133 pr_count = PullRequestModel().count_awaiting_my_review(
134 134 pull_request.target_repo, user_id=pull_request.author.user_id)
135 135 assert pr_count == 1
136 136
137 137 def test_delete_calls_cleanup_merge(self, pull_request):
138 138 PullRequestModel().delete(pull_request, pull_request.author)
139 139
140 140 self.workspace_remove_mock.assert_called_once_with(
141 141 self.workspace_id)
142 142
143 143 def test_close_calls_cleanup_and_hook(self, pull_request):
144 144 PullRequestModel().close_pull_request(
145 145 pull_request, pull_request.author)
146 146
147 147 self.workspace_remove_mock.assert_called_once_with(
148 148 self.workspace_id)
149 149 self.hook_mock.assert_called_with(
150 150 self.pull_request, self.pull_request.author, 'close')
151 151
152 152 def test_merge_status(self, pull_request):
153 153 self.merge_mock.return_value = MergeResponse(
154 154 True, False, None, MergeFailureReason.NONE)
155 155
156 156 assert pull_request._last_merge_source_rev is None
157 157 assert pull_request._last_merge_target_rev is None
158 158 assert pull_request.last_merge_status is None
159 159
160 160 status, msg = PullRequestModel().merge_status(pull_request)
161 161 assert status is True
162 162 assert msg.eval() == 'This pull request can be automatically merged.'
163 self.merge_mock.assert_called_once_with(
163 self.merge_mock.assert_called_with(
164 164 pull_request.target_ref_parts,
165 165 pull_request.source_repo.scm_instance(),
166 166 pull_request.source_ref_parts, self.workspace_id, dry_run=True,
167 167 use_rebase=False, close_branch=False)
168 168
169 169 assert pull_request._last_merge_source_rev == self.source_commit
170 170 assert pull_request._last_merge_target_rev == self.target_commit
171 171 assert pull_request.last_merge_status is MergeFailureReason.NONE
172 172
173 173 self.merge_mock.reset_mock()
174 174 status, msg = PullRequestModel().merge_status(pull_request)
175 175 assert status is True
176 176 assert msg.eval() == 'This pull request can be automatically merged.'
177 177 assert self.merge_mock.called is False
178 178
179 179 def test_merge_status_known_failure(self, pull_request):
180 180 self.merge_mock.return_value = MergeResponse(
181 181 False, False, None, MergeFailureReason.MERGE_FAILED)
182 182
183 183 assert pull_request._last_merge_source_rev is None
184 184 assert pull_request._last_merge_target_rev is None
185 185 assert pull_request.last_merge_status is None
186 186
187 187 status, msg = PullRequestModel().merge_status(pull_request)
188 188 assert status is False
189 189 assert (
190 190 msg.eval() ==
191 191 'This pull request cannot be merged because of merge conflicts.')
192 self.merge_mock.assert_called_once_with(
192 self.merge_mock.assert_called_with(
193 193 pull_request.target_ref_parts,
194 194 pull_request.source_repo.scm_instance(),
195 195 pull_request.source_ref_parts, self.workspace_id, dry_run=True,
196 196 use_rebase=False, close_branch=False)
197 197
198 198 assert pull_request._last_merge_source_rev == self.source_commit
199 199 assert pull_request._last_merge_target_rev == self.target_commit
200 200 assert (
201 201 pull_request.last_merge_status is MergeFailureReason.MERGE_FAILED)
202 202
203 203 self.merge_mock.reset_mock()
204 204 status, msg = PullRequestModel().merge_status(pull_request)
205 205 assert status is False
206 206 assert (
207 207 msg.eval() ==
208 208 'This pull request cannot be merged because of merge conflicts.')
209 209 assert self.merge_mock.called is False
210 210
211 211 def test_merge_status_unknown_failure(self, pull_request):
212 212 self.merge_mock.return_value = MergeResponse(
213 213 False, False, None, MergeFailureReason.UNKNOWN)
214 214
215 215 assert pull_request._last_merge_source_rev is None
216 216 assert pull_request._last_merge_target_rev is None
217 217 assert pull_request.last_merge_status is None
218 218
219 219 status, msg = PullRequestModel().merge_status(pull_request)
220 220 assert status is False
221 221 assert msg.eval() == (
222 222 'This pull request cannot be merged because of an unhandled'
223 223 ' exception.')
224 self.merge_mock.assert_called_once_with(
224 self.merge_mock.assert_called_with(
225 225 pull_request.target_ref_parts,
226 226 pull_request.source_repo.scm_instance(),
227 227 pull_request.source_ref_parts, self.workspace_id, dry_run=True,
228 228 use_rebase=False, close_branch=False)
229 229
230 230 assert pull_request._last_merge_source_rev is None
231 231 assert pull_request._last_merge_target_rev is None
232 232 assert pull_request.last_merge_status is None
233 233
234 234 self.merge_mock.reset_mock()
235 235 status, msg = PullRequestModel().merge_status(pull_request)
236 236 assert status is False
237 237 assert msg.eval() == (
238 238 'This pull request cannot be merged because of an unhandled'
239 239 ' exception.')
240 240 assert self.merge_mock.called is True
241 241
242 242 def test_merge_status_when_target_is_locked(self, pull_request):
243 243 pull_request.target_repo.locked = [1, u'12345.50', 'lock_web']
244 244 status, msg = PullRequestModel().merge_status(pull_request)
245 245 assert status is False
246 246 assert msg.eval() == (
247 247 'This pull request cannot be merged because the target repository'
248 248 ' is locked.')
249 249
250 250 def test_merge_status_requirements_check_target(self, pull_request):
251 251
252 252 def has_largefiles(self, repo):
253 253 return repo == pull_request.source_repo
254 254
255 255 patcher = mock.patch.object(
256 256 PullRequestModel, '_has_largefiles', has_largefiles)
257 257 with patcher:
258 258 status, msg = PullRequestModel().merge_status(pull_request)
259 259
260 260 assert status is False
261 261 assert msg == 'Target repository large files support is disabled.'
262 262
263 263 def test_merge_status_requirements_check_source(self, pull_request):
264 264
265 265 def has_largefiles(self, repo):
266 266 return repo == pull_request.target_repo
267 267
268 268 patcher = mock.patch.object(
269 269 PullRequestModel, '_has_largefiles', has_largefiles)
270 270 with patcher:
271 271 status, msg = PullRequestModel().merge_status(pull_request)
272 272
273 273 assert status is False
274 274 assert msg == 'Source repository large files support is disabled.'
275 275
276 276 def test_merge(self, pull_request, merge_extras):
277 277 user = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
278 278 merge_ref = Reference(
279 279 'type', 'name', '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
280 280 self.merge_mock.return_value = MergeResponse(
281 281 True, True, merge_ref, MergeFailureReason.NONE)
282 282
283 283 merge_extras['repository'] = pull_request.target_repo.repo_name
284 284 PullRequestModel().merge(
285 285 pull_request, pull_request.author, extras=merge_extras)
286 286
287 287 message = (
288 288 u'Merge pull request #{pr_id} from {source_repo} {source_ref_name}'
289 289 u'\n\n {pr_title}'.format(
290 290 pr_id=pull_request.pull_request_id,
291 291 source_repo=safe_unicode(
292 292 pull_request.source_repo.scm_instance().name),
293 293 source_ref_name=pull_request.source_ref_parts.name,
294 294 pr_title=safe_unicode(pull_request.title)
295 295 )
296 296 )
297 self.merge_mock.assert_called_once_with(
297 self.merge_mock.assert_called_with(
298 298 pull_request.target_ref_parts,
299 299 pull_request.source_repo.scm_instance(),
300 300 pull_request.source_ref_parts, self.workspace_id,
301 301 user_name=user.username, user_email=user.email, message=message,
302 302 use_rebase=False, close_branch=False
303 303 )
304 304 self.invalidation_mock.assert_called_once_with(
305 305 pull_request.target_repo.repo_name)
306 306
307 307 self.hook_mock.assert_called_with(
308 308 self.pull_request, self.pull_request.author, 'merge')
309 309
310 310 pull_request = PullRequest.get(pull_request.pull_request_id)
311 311 assert (
312 312 pull_request.merge_rev ==
313 313 '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
314 314
315 315 def test_merge_failed(self, pull_request, merge_extras):
316 316 user = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
317 317 merge_ref = Reference(
318 318 'type', 'name', '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
319 319 self.merge_mock.return_value = MergeResponse(
320 320 False, False, merge_ref, MergeFailureReason.MERGE_FAILED)
321 321
322 322 merge_extras['repository'] = pull_request.target_repo.repo_name
323 323 PullRequestModel().merge(
324 324 pull_request, pull_request.author, extras=merge_extras)
325 325
326 326 message = (
327 327 u'Merge pull request #{pr_id} from {source_repo} {source_ref_name}'
328 328 u'\n\n {pr_title}'.format(
329 329 pr_id=pull_request.pull_request_id,
330 330 source_repo=safe_unicode(
331 331 pull_request.source_repo.scm_instance().name),
332 332 source_ref_name=pull_request.source_ref_parts.name,
333 333 pr_title=safe_unicode(pull_request.title)
334 334 )
335 335 )
336 self.merge_mock.assert_called_once_with(
336 self.merge_mock.assert_called_with(
337 337 pull_request.target_ref_parts,
338 338 pull_request.source_repo.scm_instance(),
339 339 pull_request.source_ref_parts, self.workspace_id,
340 340 user_name=user.username, user_email=user.email, message=message,
341 341 use_rebase=False, close_branch=False
342 342 )
343 343
344 344 pull_request = PullRequest.get(pull_request.pull_request_id)
345 345 assert self.invalidation_mock.called is False
346 346 assert pull_request.merge_rev is None
347 347
348 348 def test_get_commit_ids(self, pull_request):
349 349 # The PR has been not merget yet, so expect an exception
350 350 with pytest.raises(ValueError):
351 351 PullRequestModel()._get_commit_ids(pull_request)
352 352
353 353 # Merge revision is in the revisions list
354 354 pull_request.merge_rev = pull_request.revisions[0]
355 355 commit_ids = PullRequestModel()._get_commit_ids(pull_request)
356 356 assert commit_ids == pull_request.revisions
357 357
358 358 # Merge revision is not in the revisions list
359 359 pull_request.merge_rev = 'f000' * 10
360 360 commit_ids = PullRequestModel()._get_commit_ids(pull_request)
361 361 assert commit_ids == pull_request.revisions + [pull_request.merge_rev]
362 362
363 363 def test_get_diff_from_pr_version(self, pull_request):
364 364 source_repo = pull_request.source_repo
365 365 source_ref_id = pull_request.source_ref_parts.commit_id
366 366 target_ref_id = pull_request.target_ref_parts.commit_id
367 367 diff = PullRequestModel()._get_diff_from_pr_or_version(
368 368 source_repo, source_ref_id, target_ref_id, context=6)
369 369 assert 'file_1' in diff.raw
370 370
371 371 def test_generate_title_returns_unicode(self):
372 372 title = PullRequestModel().generate_pullrequest_title(
373 373 source='source-dummy',
374 374 source_ref='source-ref-dummy',
375 375 target='target-dummy',
376 376 )
377 377 assert type(title) == unicode
378 378
379 379
380 380 @pytest.mark.usefixtures('config_stub')
381 381 class TestIntegrationMerge(object):
382 382 @pytest.mark.parametrize('extra_config', (
383 383 {'vcs.hooks.protocol': 'http', 'vcs.hooks.direct_calls': False},
384 384 ))
385 385 def test_merge_triggers_push_hooks(
386 386 self, pr_util, user_admin, capture_rcextensions, merge_extras,
387 387 extra_config):
388 388 pull_request = pr_util.create_pull_request(
389 389 approved=True, mergeable=True)
390 390 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
391 391 merge_extras['repository'] = pull_request.target_repo.repo_name
392 392 Session().commit()
393 393
394 394 with mock.patch.dict(rhodecode.CONFIG, extra_config, clear=False):
395 395 merge_state = PullRequestModel().merge(
396 396 pull_request, user_admin, extras=merge_extras)
397 397
398 398 assert merge_state.executed
399 399 assert 'pre_push' in capture_rcextensions
400 400 assert 'post_push' in capture_rcextensions
401 401
402 402 def test_merge_can_be_rejected_by_pre_push_hook(
403 403 self, pr_util, user_admin, capture_rcextensions, merge_extras):
404 404 pull_request = pr_util.create_pull_request(
405 405 approved=True, mergeable=True)
406 406 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
407 407 merge_extras['repository'] = pull_request.target_repo.repo_name
408 408 Session().commit()
409 409
410 410 with mock.patch('rhodecode.EXTENSIONS.PRE_PUSH_HOOK') as pre_pull:
411 411 pre_pull.side_effect = RepositoryError("Disallow push!")
412 412 merge_status = PullRequestModel().merge(
413 413 pull_request, user_admin, extras=merge_extras)
414 414
415 415 assert not merge_status.executed
416 416 assert 'pre_push' not in capture_rcextensions
417 417 assert 'post_push' not in capture_rcextensions
418 418
419 419 def test_merge_fails_if_target_is_locked(
420 420 self, pr_util, user_regular, merge_extras):
421 421 pull_request = pr_util.create_pull_request(
422 422 approved=True, mergeable=True)
423 423 locked_by = [user_regular.user_id + 1, 12345.50, 'lock_web']
424 424 pull_request.target_repo.locked = locked_by
425 425 # TODO: johbo: Check if this can work based on the database, currently
426 426 # all data is pre-computed, that's why just updating the DB is not
427 427 # enough.
428 428 merge_extras['locked_by'] = locked_by
429 429 merge_extras['repository'] = pull_request.target_repo.repo_name
430 430 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
431 431 Session().commit()
432 432 merge_status = PullRequestModel().merge(
433 433 pull_request, user_regular, extras=merge_extras)
434 434 assert not merge_status.executed
435 435
436 436
437 437 @pytest.mark.parametrize('use_outdated, inlines_count, outdated_count', [
438 438 (False, 1, 0),
439 439 (True, 0, 1),
440 440 ])
441 441 def test_outdated_comments(
442 442 pr_util, use_outdated, inlines_count, outdated_count, config_stub):
443 443 pull_request = pr_util.create_pull_request()
444 444 pr_util.create_inline_comment(file_path='not_in_updated_diff')
445 445
446 446 with outdated_comments_patcher(use_outdated) as outdated_comment_mock:
447 447 pr_util.add_one_commit()
448 448 assert_inline_comments(
449 449 pull_request, visible=inlines_count, outdated=outdated_count)
450 450 outdated_comment_mock.assert_called_with(pull_request)
451 451
452 452
453 453 @pytest.fixture
454 454 def merge_extras(user_regular):
455 455 """
456 456 Context for the vcs operation when running a merge.
457 457 """
458 458 extras = {
459 459 'ip': '127.0.0.1',
460 460 'username': user_regular.username,
461 461 'action': 'push',
462 462 'repository': 'fake_target_repo_name',
463 463 'scm': 'git',
464 464 'config': 'fake_config_ini_path',
465 465 'make_lock': None,
466 466 'locked_by': [None, None, None],
467 467 'server_url': 'http://test.example.com:5000',
468 468 'hooks': ['push', 'pull'],
469 469 'is_shadow_repo': False,
470 470 }
471 471 return extras
472 472
473 473
474 474 @pytest.mark.usefixtures('config_stub')
475 475 class TestUpdateCommentHandling(object):
476 476
477 477 @pytest.fixture(autouse=True, scope='class')
478 478 def enable_outdated_comments(self, request, pylonsapp):
479 479 config_patch = mock.patch.dict(
480 480 'rhodecode.CONFIG', {'rhodecode_use_outdated_comments': True})
481 481 config_patch.start()
482 482
483 483 @request.addfinalizer
484 484 def cleanup():
485 485 config_patch.stop()
486 486
487 487 def test_comment_stays_unflagged_on_unchanged_diff(self, pr_util):
488 488 commits = [
489 489 {'message': 'a'},
490 490 {'message': 'b', 'added': [FileNode('file_b', 'test_content\n')]},
491 491 {'message': 'c', 'added': [FileNode('file_c', 'test_content\n')]},
492 492 ]
493 493 pull_request = pr_util.create_pull_request(
494 494 commits=commits, target_head='a', source_head='b', revisions=['b'])
495 495 pr_util.create_inline_comment(file_path='file_b')
496 496 pr_util.add_one_commit(head='c')
497 497
498 498 assert_inline_comments(pull_request, visible=1, outdated=0)
499 499
500 500 def test_comment_stays_unflagged_on_change_above(self, pr_util):
501 501 original_content = ''.join(
502 502 ['line {}\n'.format(x) for x in range(1, 11)])
503 503 updated_content = 'new_line_at_top\n' + original_content
504 504 commits = [
505 505 {'message': 'a'},
506 506 {'message': 'b', 'added': [FileNode('file_b', original_content)]},
507 507 {'message': 'c', 'changed': [FileNode('file_b', updated_content)]},
508 508 ]
509 509 pull_request = pr_util.create_pull_request(
510 510 commits=commits, target_head='a', source_head='b', revisions=['b'])
511 511
512 512 with outdated_comments_patcher():
513 513 comment = pr_util.create_inline_comment(
514 514 line_no=u'n8', file_path='file_b')
515 515 pr_util.add_one_commit(head='c')
516 516
517 517 assert_inline_comments(pull_request, visible=1, outdated=0)
518 518 assert comment.line_no == u'n9'
519 519
520 520 def test_comment_stays_unflagged_on_change_below(self, pr_util):
521 521 original_content = ''.join(['line {}\n'.format(x) for x in range(10)])
522 522 updated_content = original_content + 'new_line_at_end\n'
523 523 commits = [
524 524 {'message': 'a'},
525 525 {'message': 'b', 'added': [FileNode('file_b', original_content)]},
526 526 {'message': 'c', 'changed': [FileNode('file_b', updated_content)]},
527 527 ]
528 528 pull_request = pr_util.create_pull_request(
529 529 commits=commits, target_head='a', source_head='b', revisions=['b'])
530 530 pr_util.create_inline_comment(file_path='file_b')
531 531 pr_util.add_one_commit(head='c')
532 532
533 533 assert_inline_comments(pull_request, visible=1, outdated=0)
534 534
535 535 @pytest.mark.parametrize('line_no', ['n4', 'o4', 'n10', 'o9'])
536 536 def test_comment_flagged_on_change_around_context(self, pr_util, line_no):
537 537 base_lines = ['line {}\n'.format(x) for x in range(1, 13)]
538 538 change_lines = list(base_lines)
539 539 change_lines.insert(6, 'line 6a added\n')
540 540
541 541 # Changes on the last line of sight
542 542 update_lines = list(change_lines)
543 543 update_lines[0] = 'line 1 changed\n'
544 544 update_lines[-1] = 'line 12 changed\n'
545 545
546 546 def file_b(lines):
547 547 return FileNode('file_b', ''.join(lines))
548 548
549 549 commits = [
550 550 {'message': 'a', 'added': [file_b(base_lines)]},
551 551 {'message': 'b', 'changed': [file_b(change_lines)]},
552 552 {'message': 'c', 'changed': [file_b(update_lines)]},
553 553 ]
554 554
555 555 pull_request = pr_util.create_pull_request(
556 556 commits=commits, target_head='a', source_head='b', revisions=['b'])
557 557 pr_util.create_inline_comment(line_no=line_no, file_path='file_b')
558 558
559 559 with outdated_comments_patcher():
560 560 pr_util.add_one_commit(head='c')
561 561 assert_inline_comments(pull_request, visible=0, outdated=1)
562 562
563 563 @pytest.mark.parametrize("change, content", [
564 564 ('changed', 'changed\n'),
565 565 ('removed', ''),
566 566 ], ids=['changed', 'removed'])
567 567 def test_comment_flagged_on_change(self, pr_util, change, content):
568 568 commits = [
569 569 {'message': 'a'},
570 570 {'message': 'b', 'added': [FileNode('file_b', 'test_content\n')]},
571 571 {'message': 'c', change: [FileNode('file_b', content)]},
572 572 ]
573 573 pull_request = pr_util.create_pull_request(
574 574 commits=commits, target_head='a', source_head='b', revisions=['b'])
575 575 pr_util.create_inline_comment(file_path='file_b')
576 576
577 577 with outdated_comments_patcher():
578 578 pr_util.add_one_commit(head='c')
579 579 assert_inline_comments(pull_request, visible=0, outdated=1)
580 580
581 581
582 582 @pytest.mark.usefixtures('config_stub')
583 583 class TestUpdateChangedFiles(object):
584 584
585 585 def test_no_changes_on_unchanged_diff(self, pr_util):
586 586 commits = [
587 587 {'message': 'a'},
588 588 {'message': 'b',
589 589 'added': [FileNode('file_b', 'test_content b\n')]},
590 590 {'message': 'c',
591 591 'added': [FileNode('file_c', 'test_content c\n')]},
592 592 ]
593 593 # open a PR from a to b, adding file_b
594 594 pull_request = pr_util.create_pull_request(
595 595 commits=commits, target_head='a', source_head='b', revisions=['b'],
596 596 name_suffix='per-file-review')
597 597
598 598 # modify PR adding new file file_c
599 599 pr_util.add_one_commit(head='c')
600 600
601 601 assert_pr_file_changes(
602 602 pull_request,
603 603 added=['file_c'],
604 604 modified=[],
605 605 removed=[])
606 606
607 607 def test_modify_and_undo_modification_diff(self, pr_util):
608 608 commits = [
609 609 {'message': 'a'},
610 610 {'message': 'b',
611 611 'added': [FileNode('file_b', 'test_content b\n')]},
612 612 {'message': 'c',
613 613 'changed': [FileNode('file_b', 'test_content b modified\n')]},
614 614 {'message': 'd',
615 615 'changed': [FileNode('file_b', 'test_content b\n')]},
616 616 ]
617 617 # open a PR from a to b, adding file_b
618 618 pull_request = pr_util.create_pull_request(
619 619 commits=commits, target_head='a', source_head='b', revisions=['b'],
620 620 name_suffix='per-file-review')
621 621
622 622 # modify PR modifying file file_b
623 623 pr_util.add_one_commit(head='c')
624 624
625 625 assert_pr_file_changes(
626 626 pull_request,
627 627 added=[],
628 628 modified=['file_b'],
629 629 removed=[])
630 630
631 631 # move the head again to d, which rollbacks change,
632 632 # meaning we should indicate no changes
633 633 pr_util.add_one_commit(head='d')
634 634
635 635 assert_pr_file_changes(
636 636 pull_request,
637 637 added=[],
638 638 modified=[],
639 639 removed=[])
640 640
641 641 def test_updated_all_files_in_pr(self, pr_util):
642 642 commits = [
643 643 {'message': 'a'},
644 644 {'message': 'b', 'added': [
645 645 FileNode('file_a', 'test_content a\n'),
646 646 FileNode('file_b', 'test_content b\n'),
647 647 FileNode('file_c', 'test_content c\n')]},
648 648 {'message': 'c', 'changed': [
649 649 FileNode('file_a', 'test_content a changed\n'),
650 650 FileNode('file_b', 'test_content b changed\n'),
651 651 FileNode('file_c', 'test_content c changed\n')]},
652 652 ]
653 653 # open a PR from a to b, changing 3 files
654 654 pull_request = pr_util.create_pull_request(
655 655 commits=commits, target_head='a', source_head='b', revisions=['b'],
656 656 name_suffix='per-file-review')
657 657
658 658 pr_util.add_one_commit(head='c')
659 659
660 660 assert_pr_file_changes(
661 661 pull_request,
662 662 added=[],
663 663 modified=['file_a', 'file_b', 'file_c'],
664 664 removed=[])
665 665
666 666 def test_updated_and_removed_all_files_in_pr(self, pr_util):
667 667 commits = [
668 668 {'message': 'a'},
669 669 {'message': 'b', 'added': [
670 670 FileNode('file_a', 'test_content a\n'),
671 671 FileNode('file_b', 'test_content b\n'),
672 672 FileNode('file_c', 'test_content c\n')]},
673 673 {'message': 'c', 'removed': [
674 674 FileNode('file_a', 'test_content a changed\n'),
675 675 FileNode('file_b', 'test_content b changed\n'),
676 676 FileNode('file_c', 'test_content c changed\n')]},
677 677 ]
678 678 # open a PR from a to b, removing 3 files
679 679 pull_request = pr_util.create_pull_request(
680 680 commits=commits, target_head='a', source_head='b', revisions=['b'],
681 681 name_suffix='per-file-review')
682 682
683 683 pr_util.add_one_commit(head='c')
684 684
685 685 assert_pr_file_changes(
686 686 pull_request,
687 687 added=[],
688 688 modified=[],
689 689 removed=['file_a', 'file_b', 'file_c'])
690 690
691 691
692 692 def test_update_writes_snapshot_into_pull_request_version(pr_util, config_stub):
693 693 model = PullRequestModel()
694 694 pull_request = pr_util.create_pull_request()
695 695 pr_util.update_source_repository()
696 696
697 697 model.update_commits(pull_request)
698 698
699 699 # Expect that it has a version entry now
700 700 assert len(model.get_versions(pull_request)) == 1
701 701
702 702
703 703 def test_update_skips_new_version_if_unchanged(pr_util, config_stub):
704 704 pull_request = pr_util.create_pull_request()
705 705 model = PullRequestModel()
706 706 model.update_commits(pull_request)
707 707
708 708 # Expect that it still has no versions
709 709 assert len(model.get_versions(pull_request)) == 0
710 710
711 711
712 712 def test_update_assigns_comments_to_the_new_version(pr_util, config_stub):
713 713 model = PullRequestModel()
714 714 pull_request = pr_util.create_pull_request()
715 715 comment = pr_util.create_comment()
716 716 pr_util.update_source_repository()
717 717
718 718 model.update_commits(pull_request)
719 719
720 720 # Expect that the comment is linked to the pr version now
721 721 assert comment.pull_request_version == model.get_versions(pull_request)[0]
722 722
723 723
724 724 def test_update_adds_a_comment_to_the_pull_request_about_the_change(pr_util, config_stub):
725 725 model = PullRequestModel()
726 726 pull_request = pr_util.create_pull_request()
727 727 pr_util.update_source_repository()
728 728 pr_util.update_source_repository()
729 729
730 730 model.update_commits(pull_request)
731 731
732 732 # Expect to find a new comment about the change
733 733 expected_message = textwrap.dedent(
734 734 """\
735 735 Pull request updated. Auto status change to |under_review|
736 736
737 737 .. role:: added
738 738 .. role:: removed
739 739 .. parsed-literal::
740 740
741 741 Changed commits:
742 742 * :added:`1 added`
743 743 * :removed:`0 removed`
744 744
745 745 Changed files:
746 746 * `A file_2 <#a_c--92ed3b5f07b4>`_
747 747
748 748 .. |under_review| replace:: *"Under Review"*"""
749 749 )
750 750 pull_request_comments = sorted(
751 751 pull_request.comments, key=lambda c: c.modified_at)
752 752 update_comment = pull_request_comments[-1]
753 753 assert update_comment.text == expected_message
754 754
755 755
756 756 def test_create_version_from_snapshot_updates_attributes(pr_util, config_stub):
757 757 pull_request = pr_util.create_pull_request()
758 758
759 759 # Avoiding default values
760 760 pull_request.status = PullRequest.STATUS_CLOSED
761 761 pull_request._last_merge_source_rev = "0" * 40
762 762 pull_request._last_merge_target_rev = "1" * 40
763 763 pull_request.last_merge_status = 1
764 764 pull_request.merge_rev = "2" * 40
765 765
766 766 # Remember automatic values
767 767 created_on = pull_request.created_on
768 768 updated_on = pull_request.updated_on
769 769
770 770 # Create a new version of the pull request
771 771 version = PullRequestModel()._create_version_from_snapshot(pull_request)
772 772
773 773 # Check attributes
774 774 assert version.title == pr_util.create_parameters['title']
775 775 assert version.description == pr_util.create_parameters['description']
776 776 assert version.status == PullRequest.STATUS_CLOSED
777 777
778 778 # versions get updated created_on
779 779 assert version.created_on != created_on
780 780
781 781 assert version.updated_on == updated_on
782 782 assert version.user_id == pull_request.user_id
783 783 assert version.revisions == pr_util.create_parameters['revisions']
784 784 assert version.source_repo == pr_util.source_repository
785 785 assert version.source_ref == pr_util.create_parameters['source_ref']
786 786 assert version.target_repo == pr_util.target_repository
787 787 assert version.target_ref == pr_util.create_parameters['target_ref']
788 788 assert version._last_merge_source_rev == pull_request._last_merge_source_rev
789 789 assert version._last_merge_target_rev == pull_request._last_merge_target_rev
790 790 assert version.last_merge_status == pull_request.last_merge_status
791 791 assert version.merge_rev == pull_request.merge_rev
792 792 assert version.pull_request == pull_request
793 793
794 794
795 795 def test_link_comments_to_version_only_updates_unlinked_comments(pr_util, config_stub):
796 796 version1 = pr_util.create_version_of_pull_request()
797 797 comment_linked = pr_util.create_comment(linked_to=version1)
798 798 comment_unlinked = pr_util.create_comment()
799 799 version2 = pr_util.create_version_of_pull_request()
800 800
801 801 PullRequestModel()._link_comments_to_version(version2)
802 802
803 803 # Expect that only the new comment is linked to version2
804 804 assert (
805 805 comment_unlinked.pull_request_version_id ==
806 806 version2.pull_request_version_id)
807 807 assert (
808 808 comment_linked.pull_request_version_id ==
809 809 version1.pull_request_version_id)
810 810 assert (
811 811 comment_unlinked.pull_request_version_id !=
812 812 comment_linked.pull_request_version_id)
813 813
814 814
815 815 def test_calculate_commits():
816 816 old_ids = [1, 2, 3]
817 817 new_ids = [1, 3, 4, 5]
818 818 change = PullRequestModel()._calculate_commit_id_changes(old_ids, new_ids)
819 819 assert change.added == [4, 5]
820 820 assert change.common == [1, 3]
821 821 assert change.removed == [2]
822 822 assert change.total == [1, 3, 4, 5]
823 823
824 824
825 825 def assert_inline_comments(pull_request, visible=None, outdated=None):
826 826 if visible is not None:
827 827 inline_comments = CommentsModel().get_inline_comments(
828 828 pull_request.target_repo.repo_id, pull_request=pull_request)
829 829 inline_cnt = CommentsModel().get_inline_comments_count(
830 830 inline_comments)
831 831 assert inline_cnt == visible
832 832 if outdated is not None:
833 833 outdated_comments = CommentsModel().get_outdated_comments(
834 834 pull_request.target_repo.repo_id, pull_request)
835 835 assert len(outdated_comments) == outdated
836 836
837 837
838 838 def assert_pr_file_changes(
839 839 pull_request, added=None, modified=None, removed=None):
840 840 pr_versions = PullRequestModel().get_versions(pull_request)
841 841 # always use first version, ie original PR to calculate changes
842 842 pull_request_version = pr_versions[0]
843 843 old_diff_data, new_diff_data = PullRequestModel()._generate_update_diffs(
844 844 pull_request, pull_request_version)
845 845 file_changes = PullRequestModel()._calculate_file_changes(
846 846 old_diff_data, new_diff_data)
847 847
848 848 assert added == file_changes.added, \
849 849 'expected added:%s vs value:%s' % (added, file_changes.added)
850 850 assert modified == file_changes.modified, \
851 851 'expected modified:%s vs value:%s' % (modified, file_changes.modified)
852 852 assert removed == file_changes.removed, \
853 853 'expected removed:%s vs value:%s' % (removed, file_changes.removed)
854 854
855 855
856 856 def outdated_comments_patcher(use_outdated=True):
857 857 return mock.patch.object(
858 858 CommentsModel, 'use_outdated_comments',
859 859 return_value=use_outdated)
@@ -1,43 +1,55 b''
1 1 # Copyright (C) 2016-2017 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 from pyramid.i18n import TranslationStringFactory, TranslationString
20 20
21 21 # Create a translation string factory for the 'rhodecode' domain.
22 22 _ = TranslationStringFactory('rhodecode')
23 23
24 24
25 25 class LazyString(object):
26 26 def __init__(self, *args, **kw):
27 27 self.args = args
28 28 self.kw = kw
29 29
30 def eval(self):
31 return _(*self.args, **self.kw)
32
33 def __unicode__(self):
34 return unicode(self.eval())
35
30 36 def __str__(self):
31 return _(*self.args, **self.kw)
37 return self.eval()
38
39 def __mod__(self, other):
40 return self.eval() % other
41
42 def format(self, *args):
43 return self.eval().format(*args)
32 44
33 45
34 46 def lazy_ugettext(*args, **kw):
35 47 """ Lazily evaluated version of _() """
36 48 return LazyString(*args, **kw)
37 49
38 50
39 51 def _pluralize(msgid1, msgid2, n, mapping=None):
40 52 if n == 1:
41 53 return _(msgid1, mapping=mapping)
42 54 else:
43 55 return _(msgid2, mapping=mapping)
General Comments 0
You need to be logged in to leave comments. Login now