##// END OF EJS Templates
comments: use proper auth user for close PR action.
marcink -
r3027:5f90488d stable
parent child Browse files
Show More
@@ -1,937 +1,937 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2018 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, pullrequestid, repoid=Optional(None)):
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: Optional, repository name or repository ID from where
53 53 the pull 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
125 125 pull_request = get_pull_request_or_error(pullrequestid)
126 126 if Optional.extract(repoid):
127 127 repo = get_repo_or_error(repoid)
128 128 else:
129 129 repo = pull_request.target_repo
130 130
131 131 if not PullRequestModel().check_user_read(
132 132 pull_request, apiuser, api=True):
133 133 raise JSONRPCError('repository `%s` or pull request `%s` '
134 134 'does not exist' % (repoid, pullrequestid))
135 135 data = pull_request.get_api_data()
136 136 return data
137 137
138 138
139 139 @jsonrpc_method()
140 140 def get_pull_requests(request, apiuser, repoid, status=Optional('new')):
141 141 """
142 142 Get all pull requests from the repository specified in `repoid`.
143 143
144 144 :param apiuser: This is filled automatically from the |authtoken|.
145 145 :type apiuser: AuthUser
146 146 :param repoid: Optional repository name or repository ID.
147 147 :type repoid: str or int
148 148 :param status: Only return pull requests with the specified status.
149 149 Valid options are.
150 150 * ``new`` (default)
151 151 * ``open``
152 152 * ``closed``
153 153 :type status: str
154 154
155 155 Example output:
156 156
157 157 .. code-block:: bash
158 158
159 159 "id": <id_given_in_input>,
160 160 "result":
161 161 [
162 162 ...
163 163 {
164 164 "pull_request_id": "<pull_request_id>",
165 165 "url": "<url>",
166 166 "title" : "<title>",
167 167 "description": "<description>",
168 168 "status": "<status>",
169 169 "created_on": "<date_time_created>",
170 170 "updated_on": "<date_time_updated>",
171 171 "commit_ids": [
172 172 ...
173 173 "<commit_id>",
174 174 "<commit_id>",
175 175 ...
176 176 ],
177 177 "review_status": "<review_status>",
178 178 "mergeable": {
179 179 "status": "<bool>",
180 180 "message: "<message>",
181 181 },
182 182 "source": {
183 183 "clone_url": "<clone_url>",
184 184 "reference":
185 185 {
186 186 "name": "<name>",
187 187 "type": "<type>",
188 188 "commit_id": "<commit_id>",
189 189 }
190 190 },
191 191 "target": {
192 192 "clone_url": "<clone_url>",
193 193 "reference":
194 194 {
195 195 "name": "<name>",
196 196 "type": "<type>",
197 197 "commit_id": "<commit_id>",
198 198 }
199 199 },
200 200 "merge": {
201 201 "clone_url": "<clone_url>",
202 202 "reference":
203 203 {
204 204 "name": "<name>",
205 205 "type": "<type>",
206 206 "commit_id": "<commit_id>",
207 207 }
208 208 },
209 209 "author": <user_obj>,
210 210 "reviewers": [
211 211 ...
212 212 {
213 213 "user": "<user_obj>",
214 214 "review_status": "<review_status>",
215 215 }
216 216 ...
217 217 ]
218 218 }
219 219 ...
220 220 ],
221 221 "error": null
222 222
223 223 """
224 224 repo = get_repo_or_error(repoid)
225 225 if not has_superadmin_permission(apiuser):
226 226 _perms = (
227 227 'repository.admin', 'repository.write', 'repository.read',)
228 228 validate_repo_permissions(apiuser, repoid, repo, _perms)
229 229
230 230 status = Optional.extract(status)
231 231 pull_requests = PullRequestModel().get_all(repo, statuses=[status])
232 232 data = [pr.get_api_data() for pr in pull_requests]
233 233 return data
234 234
235 235
236 236 @jsonrpc_method()
237 237 def merge_pull_request(
238 238 request, apiuser, pullrequestid, repoid=Optional(None),
239 239 userid=Optional(OAttr('apiuser'))):
240 240 """
241 241 Merge the pull request specified by `pullrequestid` into its target
242 242 repository.
243 243
244 244 :param apiuser: This is filled automatically from the |authtoken|.
245 245 :type apiuser: AuthUser
246 246 :param repoid: Optional, repository name or repository ID of the
247 247 target repository to which the |pr| is to be merged.
248 248 :type repoid: str or int
249 249 :param pullrequestid: ID of the pull request which shall be merged.
250 250 :type pullrequestid: int
251 251 :param userid: Merge the pull request as this user.
252 252 :type userid: Optional(str or int)
253 253
254 254 Example output:
255 255
256 256 .. code-block:: bash
257 257
258 258 "id": <id_given_in_input>,
259 259 "result": {
260 260 "executed": "<bool>",
261 261 "failure_reason": "<int>",
262 262 "merge_commit_id": "<merge_commit_id>",
263 263 "possible": "<bool>",
264 264 "merge_ref": {
265 265 "commit_id": "<commit_id>",
266 266 "type": "<type>",
267 267 "name": "<name>"
268 268 }
269 269 },
270 270 "error": null
271 271 """
272 272 pull_request = get_pull_request_or_error(pullrequestid)
273 273 if Optional.extract(repoid):
274 274 repo = get_repo_or_error(repoid)
275 275 else:
276 276 repo = pull_request.target_repo
277 277
278 278 if not isinstance(userid, Optional):
279 279 if (has_superadmin_permission(apiuser) or
280 280 HasRepoPermissionAnyApi('repository.admin')(
281 281 user=apiuser, repo_name=repo.repo_name)):
282 282 apiuser = get_user_or_error(userid)
283 283 else:
284 284 raise JSONRPCError('userid is not the same as your user')
285 285
286 286 check = MergeCheck.validate(
287 287 pull_request, auth_user=apiuser, translator=request.translate)
288 288 merge_possible = not check.failed
289 289
290 290 if not merge_possible:
291 291 error_messages = []
292 292 for err_type, error_msg in check.errors:
293 293 error_msg = request.translate(error_msg)
294 294 error_messages.append(error_msg)
295 295
296 296 reasons = ','.join(error_messages)
297 297 raise JSONRPCError(
298 298 'merge not possible for following reasons: {}'.format(reasons))
299 299
300 300 target_repo = pull_request.target_repo
301 301 extras = vcs_operation_context(
302 302 request.environ, repo_name=target_repo.repo_name,
303 303 username=apiuser.username, action='push',
304 304 scm=target_repo.repo_type)
305 305 merge_response = PullRequestModel().merge_repo(
306 306 pull_request, apiuser, extras=extras)
307 307 if merge_response.executed:
308 308 PullRequestModel().close_pull_request(
309 309 pull_request.pull_request_id, apiuser)
310 310
311 311 Session().commit()
312 312
313 313 # In previous versions the merge response directly contained the merge
314 314 # commit id. It is now contained in the merge reference object. To be
315 315 # backwards compatible we have to extract it again.
316 316 merge_response = merge_response._asdict()
317 317 merge_response['merge_commit_id'] = merge_response['merge_ref'].commit_id
318 318
319 319 return merge_response
320 320
321 321
322 322 @jsonrpc_method()
323 323 def get_pull_request_comments(
324 324 request, apiuser, pullrequestid, repoid=Optional(None)):
325 325 """
326 326 Get all comments of pull request specified with the `pullrequestid`
327 327
328 328 :param apiuser: This is filled automatically from the |authtoken|.
329 329 :type apiuser: AuthUser
330 330 :param repoid: Optional repository name or repository ID.
331 331 :type repoid: str or int
332 332 :param pullrequestid: The pull request ID.
333 333 :type pullrequestid: int
334 334
335 335 Example output:
336 336
337 337 .. code-block:: bash
338 338
339 339 id : <id_given_in_input>
340 340 result : [
341 341 {
342 342 "comment_author": {
343 343 "active": true,
344 344 "full_name_or_username": "Tom Gore",
345 345 "username": "admin"
346 346 },
347 347 "comment_created_on": "2017-01-02T18:43:45.533",
348 348 "comment_f_path": null,
349 349 "comment_id": 25,
350 350 "comment_lineno": null,
351 351 "comment_status": {
352 352 "status": "under_review",
353 353 "status_lbl": "Under Review"
354 354 },
355 355 "comment_text": "Example text",
356 356 "comment_type": null,
357 357 "pull_request_version": null
358 358 }
359 359 ],
360 360 error : null
361 361 """
362 362
363 363 pull_request = get_pull_request_or_error(pullrequestid)
364 364 if Optional.extract(repoid):
365 365 repo = get_repo_or_error(repoid)
366 366 else:
367 367 repo = pull_request.target_repo
368 368
369 369 if not PullRequestModel().check_user_read(
370 370 pull_request, apiuser, api=True):
371 371 raise JSONRPCError('repository `%s` or pull request `%s` '
372 372 'does not exist' % (repoid, pullrequestid))
373 373
374 374 (pull_request_latest,
375 375 pull_request_at_ver,
376 376 pull_request_display_obj,
377 377 at_version) = PullRequestModel().get_pr_version(
378 378 pull_request.pull_request_id, version=None)
379 379
380 380 versions = pull_request_display_obj.versions()
381 381 ver_map = {
382 382 ver.pull_request_version_id: cnt
383 383 for cnt, ver in enumerate(versions, 1)
384 384 }
385 385
386 386 # GENERAL COMMENTS with versions #
387 387 q = CommentsModel()._all_general_comments_of_pull_request(pull_request)
388 388 q = q.order_by(ChangesetComment.comment_id.asc())
389 389 general_comments = q.all()
390 390
391 391 # INLINE COMMENTS with versions #
392 392 q = CommentsModel()._all_inline_comments_of_pull_request(pull_request)
393 393 q = q.order_by(ChangesetComment.comment_id.asc())
394 394 inline_comments = q.all()
395 395
396 396 data = []
397 397 for comment in inline_comments + general_comments:
398 398 full_data = comment.get_api_data()
399 399 pr_version_id = None
400 400 if comment.pull_request_version_id:
401 401 pr_version_id = 'v{}'.format(
402 402 ver_map[comment.pull_request_version_id])
403 403
404 404 # sanitize some entries
405 405
406 406 full_data['pull_request_version'] = pr_version_id
407 407 full_data['comment_author'] = {
408 408 'username': full_data['comment_author'].username,
409 409 'full_name_or_username': full_data['comment_author'].full_name_or_username,
410 410 'active': full_data['comment_author'].active,
411 411 }
412 412
413 413 if full_data['comment_status']:
414 414 full_data['comment_status'] = {
415 415 'status': full_data['comment_status'][0].status,
416 416 'status_lbl': full_data['comment_status'][0].status_lbl,
417 417 }
418 418 else:
419 419 full_data['comment_status'] = {}
420 420
421 421 data.append(full_data)
422 422 return data
423 423
424 424
425 425 @jsonrpc_method()
426 426 def comment_pull_request(
427 427 request, apiuser, pullrequestid, repoid=Optional(None),
428 428 message=Optional(None), commit_id=Optional(None), status=Optional(None),
429 429 comment_type=Optional(ChangesetComment.COMMENT_TYPE_NOTE),
430 430 resolves_comment_id=Optional(None),
431 431 userid=Optional(OAttr('apiuser'))):
432 432 """
433 433 Comment on the pull request specified with the `pullrequestid`,
434 434 in the |repo| specified by the `repoid`, and optionally change the
435 435 review status.
436 436
437 437 :param apiuser: This is filled automatically from the |authtoken|.
438 438 :type apiuser: AuthUser
439 439 :param repoid: Optional repository name or repository ID.
440 440 :type repoid: str or int
441 441 :param pullrequestid: The pull request ID.
442 442 :type pullrequestid: int
443 443 :param commit_id: Specify the commit_id for which to set a comment. If
444 444 given commit_id is different than latest in the PR status
445 445 change won't be performed.
446 446 :type commit_id: str
447 447 :param message: The text content of the comment.
448 448 :type message: str
449 449 :param status: (**Optional**) Set the approval status of the pull
450 450 request. One of: 'not_reviewed', 'approved', 'rejected',
451 451 'under_review'
452 452 :type status: str
453 453 :param comment_type: Comment type, one of: 'note', 'todo'
454 454 :type comment_type: Optional(str), default: 'note'
455 455 :param userid: Comment on the pull request as this user
456 456 :type userid: Optional(str or int)
457 457
458 458 Example output:
459 459
460 460 .. code-block:: bash
461 461
462 462 id : <id_given_in_input>
463 463 result : {
464 464 "pull_request_id": "<Integer>",
465 465 "comment_id": "<Integer>",
466 466 "status": {"given": <given_status>,
467 467 "was_changed": <bool status_was_actually_changed> },
468 468 },
469 469 error : null
470 470 """
471 471 pull_request = get_pull_request_or_error(pullrequestid)
472 472 if Optional.extract(repoid):
473 473 repo = get_repo_or_error(repoid)
474 474 else:
475 475 repo = pull_request.target_repo
476 476
477 477 if not isinstance(userid, Optional):
478 478 if (has_superadmin_permission(apiuser) or
479 479 HasRepoPermissionAnyApi('repository.admin')(
480 480 user=apiuser, repo_name=repo.repo_name)):
481 481 apiuser = get_user_or_error(userid)
482 482 else:
483 483 raise JSONRPCError('userid is not the same as your user')
484 484
485 485 if not PullRequestModel().check_user_read(
486 486 pull_request, apiuser, api=True):
487 487 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
488 488 message = Optional.extract(message)
489 489 status = Optional.extract(status)
490 490 commit_id = Optional.extract(commit_id)
491 491 comment_type = Optional.extract(comment_type)
492 492 resolves_comment_id = Optional.extract(resolves_comment_id)
493 493
494 494 if not message and not status:
495 495 raise JSONRPCError(
496 496 'Both message and status parameters are missing. '
497 497 'At least one is required.')
498 498
499 499 if (status not in (st[0] for st in ChangesetStatus.STATUSES) and
500 500 status is not None):
501 501 raise JSONRPCError('Unknown comment status: `%s`' % status)
502 502
503 503 if commit_id and commit_id not in pull_request.revisions:
504 504 raise JSONRPCError(
505 505 'Invalid commit_id `%s` for this pull request.' % commit_id)
506 506
507 507 allowed_to_change_status = PullRequestModel().check_user_change_status(
508 508 pull_request, apiuser)
509 509
510 510 # if commit_id is passed re-validated if user is allowed to change status
511 511 # based on latest commit_id from the PR
512 512 if commit_id:
513 513 commit_idx = pull_request.revisions.index(commit_id)
514 514 if commit_idx != 0:
515 515 allowed_to_change_status = False
516 516
517 517 if resolves_comment_id:
518 518 comment = ChangesetComment.get(resolves_comment_id)
519 519 if not comment:
520 520 raise JSONRPCError(
521 521 'Invalid resolves_comment_id `%s` for this pull request.'
522 522 % resolves_comment_id)
523 523 if comment.comment_type != ChangesetComment.COMMENT_TYPE_TODO:
524 524 raise JSONRPCError(
525 525 'Comment `%s` is wrong type for setting status to resolved.'
526 526 % resolves_comment_id)
527 527
528 528 text = message
529 529 status_label = ChangesetStatus.get_status_lbl(status)
530 530 if status and allowed_to_change_status:
531 531 st_message = ('Status change %(transition_icon)s %(status)s'
532 532 % {'transition_icon': '>', 'status': status_label})
533 533 text = message or st_message
534 534
535 535 rc_config = SettingsModel().get_all_settings()
536 536 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
537 537
538 538 status_change = status and allowed_to_change_status
539 539 comment = CommentsModel().create(
540 540 text=text,
541 541 repo=pull_request.target_repo.repo_id,
542 542 user=apiuser.user_id,
543 543 pull_request=pull_request.pull_request_id,
544 544 f_path=None,
545 545 line_no=None,
546 546 status_change=(status_label if status_change else None),
547 547 status_change_type=(status if status_change else None),
548 548 closing_pr=False,
549 549 renderer=renderer,
550 550 comment_type=comment_type,
551 551 resolves_comment_id=resolves_comment_id,
552 552 auth_user=apiuser
553 553 )
554 554
555 555 if allowed_to_change_status and status:
556 556 ChangesetStatusModel().set_status(
557 557 pull_request.target_repo.repo_id,
558 558 status,
559 559 apiuser.user_id,
560 560 comment,
561 561 pull_request=pull_request.pull_request_id
562 562 )
563 563 Session().flush()
564 564
565 565 Session().commit()
566 566 data = {
567 567 'pull_request_id': pull_request.pull_request_id,
568 568 'comment_id': comment.comment_id if comment else None,
569 569 'status': {'given': status, 'was_changed': status_change},
570 570 }
571 571 return data
572 572
573 573
574 574 @jsonrpc_method()
575 575 def create_pull_request(
576 576 request, apiuser, source_repo, target_repo, source_ref, target_ref,
577 577 title=Optional(''), description=Optional(''), description_renderer=Optional(''),
578 578 reviewers=Optional(None)):
579 579 """
580 580 Creates a new pull request.
581 581
582 582 Accepts refs in the following formats:
583 583
584 584 * branch:<branch_name>:<sha>
585 585 * branch:<branch_name>
586 586 * bookmark:<bookmark_name>:<sha> (Mercurial only)
587 587 * bookmark:<bookmark_name> (Mercurial only)
588 588
589 589 :param apiuser: This is filled automatically from the |authtoken|.
590 590 :type apiuser: AuthUser
591 591 :param source_repo: Set the source repository name.
592 592 :type source_repo: str
593 593 :param target_repo: Set the target repository name.
594 594 :type target_repo: str
595 595 :param source_ref: Set the source ref name.
596 596 :type source_ref: str
597 597 :param target_ref: Set the target ref name.
598 598 :type target_ref: str
599 599 :param title: Optionally Set the pull request title, it's generated otherwise
600 600 :type title: str
601 601 :param description: Set the pull request description.
602 602 :type description: Optional(str)
603 603 :type description_renderer: Optional(str)
604 604 :param description_renderer: Set pull request renderer for the description.
605 605 It should be 'rst', 'markdown' or 'plain'. If not give default
606 606 system renderer will be used
607 607 :param reviewers: Set the new pull request reviewers list.
608 608 Reviewer defined by review rules will be added automatically to the
609 609 defined list.
610 610 :type reviewers: Optional(list)
611 611 Accepts username strings or objects of the format:
612 612
613 613 [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}]
614 614 """
615 615
616 616 source_db_repo = get_repo_or_error(source_repo)
617 617 target_db_repo = get_repo_or_error(target_repo)
618 618 if not has_superadmin_permission(apiuser):
619 619 _perms = ('repository.admin', 'repository.write', 'repository.read',)
620 620 validate_repo_permissions(apiuser, source_repo, source_db_repo, _perms)
621 621
622 622 full_source_ref = resolve_ref_or_error(source_ref, source_db_repo)
623 623 full_target_ref = resolve_ref_or_error(target_ref, target_db_repo)
624 624
625 625 source_scm = source_db_repo.scm_instance()
626 626 target_scm = target_db_repo.scm_instance()
627 627
628 628 source_commit = get_commit_or_error(full_source_ref, source_db_repo)
629 629 target_commit = get_commit_or_error(full_target_ref, target_db_repo)
630 630
631 631 ancestor = source_scm.get_common_ancestor(
632 632 source_commit.raw_id, target_commit.raw_id, target_scm)
633 633 if not ancestor:
634 634 raise JSONRPCError('no common ancestor found')
635 635
636 636 # recalculate target ref based on ancestor
637 637 target_ref_type, target_ref_name, __ = full_target_ref.split(':')
638 638 full_target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
639 639
640 640 commit_ranges = target_scm.compare(
641 641 target_commit.raw_id, source_commit.raw_id, source_scm,
642 642 merge=True, pre_load=[])
643 643
644 644 if not commit_ranges:
645 645 raise JSONRPCError('no commits found')
646 646
647 647 reviewer_objects = Optional.extract(reviewers) or []
648 648
649 649 # serialize and validate passed in given reviewers
650 650 if reviewer_objects:
651 651 schema = ReviewerListSchema()
652 652 try:
653 653 reviewer_objects = schema.deserialize(reviewer_objects)
654 654 except Invalid as err:
655 655 raise JSONRPCValidationError(colander_exc=err)
656 656
657 657 # validate users
658 658 for reviewer_object in reviewer_objects:
659 659 user = get_user_or_error(reviewer_object['username'])
660 660 reviewer_object['user_id'] = user.user_id
661 661
662 662 get_default_reviewers_data, validate_default_reviewers = \
663 663 PullRequestModel().get_reviewer_functions()
664 664
665 665 # recalculate reviewers logic, to make sure we can validate this
666 666 reviewer_rules = get_default_reviewers_data(
667 667 apiuser.get_instance(), source_db_repo,
668 668 source_commit, target_db_repo, target_commit)
669 669
670 670 # now MERGE our given with the calculated
671 671 reviewer_objects = reviewer_rules['reviewers'] + reviewer_objects
672 672
673 673 try:
674 674 reviewers = validate_default_reviewers(
675 675 reviewer_objects, reviewer_rules)
676 676 except ValueError as e:
677 677 raise JSONRPCError('Reviewers Validation: {}'.format(e))
678 678
679 679 title = Optional.extract(title)
680 680 if not title:
681 681 title_source_ref = source_ref.split(':', 2)[1]
682 682 title = PullRequestModel().generate_pullrequest_title(
683 683 source=source_repo,
684 684 source_ref=title_source_ref,
685 685 target=target_repo
686 686 )
687 687 # fetch renderer, if set fallback to plain in case of PR
688 688 rc_config = SettingsModel().get_all_settings()
689 689 default_system_renderer = rc_config.get('rhodecode_markup_renderer', 'plain')
690 690 description = Optional.extract(description)
691 691 description_renderer = Optional.extract(description_renderer) or default_system_renderer
692 692
693 693 pull_request = PullRequestModel().create(
694 694 created_by=apiuser.user_id,
695 695 source_repo=source_repo,
696 696 source_ref=full_source_ref,
697 697 target_repo=target_repo,
698 698 target_ref=full_target_ref,
699 699 revisions=[commit.raw_id for commit in reversed(commit_ranges)],
700 700 reviewers=reviewers,
701 701 title=title,
702 702 description=description,
703 703 description_renderer=description_renderer,
704 704 reviewer_data=reviewer_rules,
705 705 auth_user=apiuser
706 706 )
707 707
708 708 Session().commit()
709 709 data = {
710 710 'msg': 'Created new pull request `{}`'.format(title),
711 711 'pull_request_id': pull_request.pull_request_id,
712 712 }
713 713 return data
714 714
715 715
716 716 @jsonrpc_method()
717 717 def update_pull_request(
718 718 request, apiuser, pullrequestid, repoid=Optional(None),
719 719 title=Optional(''), description=Optional(''), description_renderer=Optional(''),
720 720 reviewers=Optional(None), update_commits=Optional(None)):
721 721 """
722 722 Updates a pull request.
723 723
724 724 :param apiuser: This is filled automatically from the |authtoken|.
725 725 :type apiuser: AuthUser
726 726 :param repoid: Optional repository name or repository ID.
727 727 :type repoid: str or int
728 728 :param pullrequestid: The pull request ID.
729 729 :type pullrequestid: int
730 730 :param title: Set the pull request title.
731 731 :type title: str
732 732 :param description: Update pull request description.
733 733 :type description: Optional(str)
734 734 :type description_renderer: Optional(str)
735 735 :param description_renderer: Update pull request renderer for the description.
736 736 It should be 'rst', 'markdown' or 'plain'
737 737 :param reviewers: Update pull request reviewers list with new value.
738 738 :type reviewers: Optional(list)
739 739 Accepts username strings or objects of the format:
740 740
741 741 [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}]
742 742
743 743 :param update_commits: Trigger update of commits for this pull request
744 744 :type: update_commits: Optional(bool)
745 745
746 746 Example output:
747 747
748 748 .. code-block:: bash
749 749
750 750 id : <id_given_in_input>
751 751 result : {
752 752 "msg": "Updated pull request `63`",
753 753 "pull_request": <pull_request_object>,
754 754 "updated_reviewers": {
755 755 "added": [
756 756 "username"
757 757 ],
758 758 "removed": []
759 759 },
760 760 "updated_commits": {
761 761 "added": [
762 762 "<sha1_hash>"
763 763 ],
764 764 "common": [
765 765 "<sha1_hash>",
766 766 "<sha1_hash>",
767 767 ],
768 768 "removed": []
769 769 }
770 770 }
771 771 error : null
772 772 """
773 773
774 774 pull_request = get_pull_request_or_error(pullrequestid)
775 775 if Optional.extract(repoid):
776 776 repo = get_repo_or_error(repoid)
777 777 else:
778 778 repo = pull_request.target_repo
779 779
780 780 if not PullRequestModel().check_user_update(
781 781 pull_request, apiuser, api=True):
782 782 raise JSONRPCError(
783 783 'pull request `%s` update failed, no permission to update.' % (
784 784 pullrequestid,))
785 785 if pull_request.is_closed():
786 786 raise JSONRPCError(
787 787 'pull request `%s` update failed, pull request is closed' % (
788 788 pullrequestid,))
789 789
790 790 reviewer_objects = Optional.extract(reviewers) or []
791 791
792 792 if reviewer_objects:
793 793 schema = ReviewerListSchema()
794 794 try:
795 795 reviewer_objects = schema.deserialize(reviewer_objects)
796 796 except Invalid as err:
797 797 raise JSONRPCValidationError(colander_exc=err)
798 798
799 799 # validate users
800 800 for reviewer_object in reviewer_objects:
801 801 user = get_user_or_error(reviewer_object['username'])
802 802 reviewer_object['user_id'] = user.user_id
803 803
804 804 get_default_reviewers_data, get_validated_reviewers = \
805 805 PullRequestModel().get_reviewer_functions()
806 806
807 807 # re-use stored rules
808 808 reviewer_rules = pull_request.reviewer_data
809 809 try:
810 810 reviewers = get_validated_reviewers(
811 811 reviewer_objects, reviewer_rules)
812 812 except ValueError as e:
813 813 raise JSONRPCError('Reviewers Validation: {}'.format(e))
814 814 else:
815 815 reviewers = []
816 816
817 817 title = Optional.extract(title)
818 818 description = Optional.extract(description)
819 819 description_renderer = Optional.extract(description_renderer)
820 820
821 821 if title or description:
822 822 PullRequestModel().edit(
823 823 pull_request,
824 824 title or pull_request.title,
825 825 description or pull_request.description,
826 826 description_renderer or pull_request.description_renderer,
827 827 apiuser)
828 828 Session().commit()
829 829
830 830 commit_changes = {"added": [], "common": [], "removed": []}
831 831 if str2bool(Optional.extract(update_commits)):
832 832 if PullRequestModel().has_valid_update_type(pull_request):
833 833 update_response = PullRequestModel().update_commits(
834 834 pull_request)
835 835 commit_changes = update_response.changes or commit_changes
836 836 Session().commit()
837 837
838 838 reviewers_changes = {"added": [], "removed": []}
839 839 if reviewers:
840 840 added_reviewers, removed_reviewers = \
841 841 PullRequestModel().update_reviewers(pull_request, reviewers, apiuser)
842 842
843 843 reviewers_changes['added'] = sorted(
844 844 [get_user_or_error(n).username for n in added_reviewers])
845 845 reviewers_changes['removed'] = sorted(
846 846 [get_user_or_error(n).username for n in removed_reviewers])
847 847 Session().commit()
848 848
849 849 data = {
850 850 'msg': 'Updated pull request `{}`'.format(
851 851 pull_request.pull_request_id),
852 852 'pull_request': pull_request.get_api_data(),
853 853 'updated_commits': commit_changes,
854 854 'updated_reviewers': reviewers_changes
855 855 }
856 856
857 857 return data
858 858
859 859
860 860 @jsonrpc_method()
861 861 def close_pull_request(
862 862 request, apiuser, pullrequestid, repoid=Optional(None),
863 863 userid=Optional(OAttr('apiuser')), message=Optional('')):
864 864 """
865 865 Close the pull request specified by `pullrequestid`.
866 866
867 867 :param apiuser: This is filled automatically from the |authtoken|.
868 868 :type apiuser: AuthUser
869 869 :param repoid: Repository name or repository ID to which the pull
870 870 request belongs.
871 871 :type repoid: str or int
872 872 :param pullrequestid: ID of the pull request to be closed.
873 873 :type pullrequestid: int
874 874 :param userid: Close the pull request as this user.
875 875 :type userid: Optional(str or int)
876 876 :param message: Optional message to close the Pull Request with. If not
877 877 specified it will be generated automatically.
878 878 :type message: Optional(str)
879 879
880 880 Example output:
881 881
882 882 .. code-block:: bash
883 883
884 884 "id": <id_given_in_input>,
885 885 "result": {
886 886 "pull_request_id": "<int>",
887 887 "close_status": "<str:status_lbl>,
888 888 "closed": "<bool>"
889 889 },
890 890 "error": null
891 891
892 892 """
893 893 _ = request.translate
894 894
895 895 pull_request = get_pull_request_or_error(pullrequestid)
896 896 if Optional.extract(repoid):
897 897 repo = get_repo_or_error(repoid)
898 898 else:
899 899 repo = pull_request.target_repo
900 900
901 901 if not isinstance(userid, Optional):
902 902 if (has_superadmin_permission(apiuser) or
903 903 HasRepoPermissionAnyApi('repository.admin')(
904 904 user=apiuser, repo_name=repo.repo_name)):
905 905 apiuser = get_user_or_error(userid)
906 906 else:
907 907 raise JSONRPCError('userid is not the same as your user')
908 908
909 909 if pull_request.is_closed():
910 910 raise JSONRPCError(
911 911 'pull request `%s` is already closed' % (pullrequestid,))
912 912
913 913 # only owner or admin or person with write permissions
914 914 allowed_to_close = PullRequestModel().check_user_update(
915 915 pull_request, apiuser, api=True)
916 916
917 917 if not allowed_to_close:
918 918 raise JSONRPCError(
919 919 'pull request `%s` close failed, no permission to close.' % (
920 920 pullrequestid,))
921 921
922 922 # message we're using to close the PR, else it's automatically generated
923 923 message = Optional.extract(message)
924 924
925 925 # finally close the PR, with proper message comment
926 926 comment, status = PullRequestModel().close_pull_request_with_comment(
927 pull_request, apiuser, repo, message=message)
927 pull_request, apiuser, repo, message=message, auth_user=apiuser)
928 928 status_lbl = ChangesetStatus.get_status_lbl(status)
929 929
930 930 Session().commit()
931 931
932 932 data = {
933 933 'pull_request_id': pull_request.pull_request_id,
934 934 'close_status': status_lbl,
935 935 'closed': True,
936 936 }
937 937 return data
@@ -1,1325 +1,1326 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2018 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.diffs import load_cached_diff, cache_diff, diff_cache_exist
38 38 from rhodecode.lib.ext_json import json
39 39 from rhodecode.lib.auth import (
40 40 LoginRequired, HasRepoPermissionAny, HasRepoPermissionAnyDecorator,
41 41 NotAnonymous, CSRFRequired)
42 42 from rhodecode.lib.utils2 import str2bool, safe_str, safe_unicode
43 43 from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason
44 44 from rhodecode.lib.vcs.exceptions import (CommitDoesNotExistError,
45 45 RepositoryRequirementError, EmptyRepositoryError)
46 46 from rhodecode.model.changeset_status import ChangesetStatusModel
47 47 from rhodecode.model.comment import CommentsModel
48 48 from rhodecode.model.db import (func, or_, PullRequest, PullRequestVersion,
49 49 ChangesetComment, ChangesetStatus, Repository)
50 50 from rhodecode.model.forms import PullRequestForm
51 51 from rhodecode.model.meta import Session
52 52 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
53 53 from rhodecode.model.scm import ScmModel
54 54
55 55 log = logging.getLogger(__name__)
56 56
57 57
58 58 class RepoPullRequestsView(RepoAppView, DataGridAppView):
59 59
60 60 def load_default_context(self):
61 61 c = self._get_local_tmpl_context(include_app_defaults=True)
62 62 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
63 63 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
64 64 # backward compat., we use for OLD PRs a plain renderer
65 65 c.renderer = 'plain'
66 66 return c
67 67
68 68 def _get_pull_requests_list(
69 69 self, repo_name, source, filter_type, opened_by, statuses):
70 70
71 71 draw, start, limit = self._extract_chunk(self.request)
72 72 search_q, order_by, order_dir = self._extract_ordering(self.request)
73 73 _render = self.request.get_partial_renderer(
74 74 'rhodecode:templates/data_table/_dt_elements.mako')
75 75
76 76 # pagination
77 77
78 78 if filter_type == 'awaiting_review':
79 79 pull_requests = PullRequestModel().get_awaiting_review(
80 80 repo_name, source=source, opened_by=opened_by,
81 81 statuses=statuses, offset=start, length=limit,
82 82 order_by=order_by, order_dir=order_dir)
83 83 pull_requests_total_count = PullRequestModel().count_awaiting_review(
84 84 repo_name, source=source, statuses=statuses,
85 85 opened_by=opened_by)
86 86 elif filter_type == 'awaiting_my_review':
87 87 pull_requests = PullRequestModel().get_awaiting_my_review(
88 88 repo_name, source=source, opened_by=opened_by,
89 89 user_id=self._rhodecode_user.user_id, statuses=statuses,
90 90 offset=start, length=limit, order_by=order_by,
91 91 order_dir=order_dir)
92 92 pull_requests_total_count = PullRequestModel().count_awaiting_my_review(
93 93 repo_name, source=source, user_id=self._rhodecode_user.user_id,
94 94 statuses=statuses, opened_by=opened_by)
95 95 else:
96 96 pull_requests = PullRequestModel().get_all(
97 97 repo_name, source=source, opened_by=opened_by,
98 98 statuses=statuses, offset=start, length=limit,
99 99 order_by=order_by, order_dir=order_dir)
100 100 pull_requests_total_count = PullRequestModel().count_all(
101 101 repo_name, source=source, statuses=statuses,
102 102 opened_by=opened_by)
103 103
104 104 data = []
105 105 comments_model = CommentsModel()
106 106 for pr in pull_requests:
107 107 comments = comments_model.get_all_comments(
108 108 self.db_repo.repo_id, pull_request=pr)
109 109
110 110 data.append({
111 111 'name': _render('pullrequest_name',
112 112 pr.pull_request_id, pr.target_repo.repo_name),
113 113 'name_raw': pr.pull_request_id,
114 114 'status': _render('pullrequest_status',
115 115 pr.calculated_review_status()),
116 116 'title': _render(
117 117 'pullrequest_title', pr.title, pr.description),
118 118 'description': h.escape(pr.description),
119 119 'updated_on': _render('pullrequest_updated_on',
120 120 h.datetime_to_time(pr.updated_on)),
121 121 'updated_on_raw': h.datetime_to_time(pr.updated_on),
122 122 'created_on': _render('pullrequest_updated_on',
123 123 h.datetime_to_time(pr.created_on)),
124 124 'created_on_raw': h.datetime_to_time(pr.created_on),
125 125 'author': _render('pullrequest_author',
126 126 pr.author.full_contact, ),
127 127 'author_raw': pr.author.full_name,
128 128 'comments': _render('pullrequest_comments', len(comments)),
129 129 'comments_raw': len(comments),
130 130 'closed': pr.is_closed(),
131 131 })
132 132
133 133 data = ({
134 134 'draw': draw,
135 135 'data': data,
136 136 'recordsTotal': pull_requests_total_count,
137 137 'recordsFiltered': pull_requests_total_count,
138 138 })
139 139 return data
140 140
141 141 @LoginRequired()
142 142 @HasRepoPermissionAnyDecorator(
143 143 'repository.read', 'repository.write', 'repository.admin')
144 144 @view_config(
145 145 route_name='pullrequest_show_all', request_method='GET',
146 146 renderer='rhodecode:templates/pullrequests/pullrequests.mako')
147 147 def pull_request_list(self):
148 148 c = self.load_default_context()
149 149
150 150 req_get = self.request.GET
151 151 c.source = str2bool(req_get.get('source'))
152 152 c.closed = str2bool(req_get.get('closed'))
153 153 c.my = str2bool(req_get.get('my'))
154 154 c.awaiting_review = str2bool(req_get.get('awaiting_review'))
155 155 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
156 156
157 157 c.active = 'open'
158 158 if c.my:
159 159 c.active = 'my'
160 160 if c.closed:
161 161 c.active = 'closed'
162 162 if c.awaiting_review and not c.source:
163 163 c.active = 'awaiting'
164 164 if c.source and not c.awaiting_review:
165 165 c.active = 'source'
166 166 if c.awaiting_my_review:
167 167 c.active = 'awaiting_my'
168 168
169 169 return self._get_template_context(c)
170 170
171 171 @LoginRequired()
172 172 @HasRepoPermissionAnyDecorator(
173 173 'repository.read', 'repository.write', 'repository.admin')
174 174 @view_config(
175 175 route_name='pullrequest_show_all_data', request_method='GET',
176 176 renderer='json_ext', xhr=True)
177 177 def pull_request_list_data(self):
178 178 self.load_default_context()
179 179
180 180 # additional filters
181 181 req_get = self.request.GET
182 182 source = str2bool(req_get.get('source'))
183 183 closed = str2bool(req_get.get('closed'))
184 184 my = str2bool(req_get.get('my'))
185 185 awaiting_review = str2bool(req_get.get('awaiting_review'))
186 186 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
187 187
188 188 filter_type = 'awaiting_review' if awaiting_review \
189 189 else 'awaiting_my_review' if awaiting_my_review \
190 190 else None
191 191
192 192 opened_by = None
193 193 if my:
194 194 opened_by = [self._rhodecode_user.user_id]
195 195
196 196 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
197 197 if closed:
198 198 statuses = [PullRequest.STATUS_CLOSED]
199 199
200 200 data = self._get_pull_requests_list(
201 201 repo_name=self.db_repo_name, source=source,
202 202 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
203 203
204 204 return data
205 205
206 206 def _is_diff_cache_enabled(self, target_repo):
207 207 caching_enabled = self._get_general_setting(
208 208 target_repo, 'rhodecode_diff_cache')
209 209 log.debug('Diff caching enabled: %s', caching_enabled)
210 210 return caching_enabled
211 211
212 212 def _get_diffset(self, source_repo_name, source_repo,
213 213 source_ref_id, target_ref_id,
214 214 target_commit, source_commit, diff_limit, file_limit,
215 215 fulldiff):
216 216
217 217 vcs_diff = PullRequestModel().get_diff(
218 218 source_repo, source_ref_id, target_ref_id)
219 219
220 220 diff_processor = diffs.DiffProcessor(
221 221 vcs_diff, format='newdiff', diff_limit=diff_limit,
222 222 file_limit=file_limit, show_full_diff=fulldiff)
223 223
224 224 _parsed = diff_processor.prepare()
225 225
226 226 diffset = codeblocks.DiffSet(
227 227 repo_name=self.db_repo_name,
228 228 source_repo_name=source_repo_name,
229 229 source_node_getter=codeblocks.diffset_node_getter(target_commit),
230 230 target_node_getter=codeblocks.diffset_node_getter(source_commit),
231 231 )
232 232 diffset = self.path_filter.render_patchset_filtered(
233 233 diffset, _parsed, target_commit.raw_id, source_commit.raw_id)
234 234
235 235 return diffset
236 236
237 237 @LoginRequired()
238 238 @HasRepoPermissionAnyDecorator(
239 239 'repository.read', 'repository.write', 'repository.admin')
240 240 @view_config(
241 241 route_name='pullrequest_show', request_method='GET',
242 242 renderer='rhodecode:templates/pullrequests/pullrequest_show.mako')
243 243 def pull_request_show(self):
244 244 pull_request_id = self.request.matchdict['pull_request_id']
245 245
246 246 c = self.load_default_context()
247 247
248 248 version = self.request.GET.get('version')
249 249 from_version = self.request.GET.get('from_version') or version
250 250 merge_checks = self.request.GET.get('merge_checks')
251 251 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
252 252 force_refresh = str2bool(self.request.GET.get('force_refresh'))
253 253
254 254 (pull_request_latest,
255 255 pull_request_at_ver,
256 256 pull_request_display_obj,
257 257 at_version) = PullRequestModel().get_pr_version(
258 258 pull_request_id, version=version)
259 259 pr_closed = pull_request_latest.is_closed()
260 260
261 261 if pr_closed and (version or from_version):
262 262 # not allow to browse versions
263 263 raise HTTPFound(h.route_path(
264 264 'pullrequest_show', repo_name=self.db_repo_name,
265 265 pull_request_id=pull_request_id))
266 266
267 267 versions = pull_request_display_obj.versions()
268 268
269 269 c.at_version = at_version
270 270 c.at_version_num = (at_version
271 271 if at_version and at_version != 'latest'
272 272 else None)
273 273 c.at_version_pos = ChangesetComment.get_index_from_version(
274 274 c.at_version_num, versions)
275 275
276 276 (prev_pull_request_latest,
277 277 prev_pull_request_at_ver,
278 278 prev_pull_request_display_obj,
279 279 prev_at_version) = PullRequestModel().get_pr_version(
280 280 pull_request_id, version=from_version)
281 281
282 282 c.from_version = prev_at_version
283 283 c.from_version_num = (prev_at_version
284 284 if prev_at_version and prev_at_version != 'latest'
285 285 else None)
286 286 c.from_version_pos = ChangesetComment.get_index_from_version(
287 287 c.from_version_num, versions)
288 288
289 289 # define if we're in COMPARE mode or VIEW at version mode
290 290 compare = at_version != prev_at_version
291 291
292 292 # pull_requests repo_name we opened it against
293 293 # ie. target_repo must match
294 294 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
295 295 raise HTTPNotFound()
296 296
297 297 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(
298 298 pull_request_at_ver)
299 299
300 300 c.pull_request = pull_request_display_obj
301 301 c.renderer = pull_request_at_ver.description_renderer or c.renderer
302 302 c.pull_request_latest = pull_request_latest
303 303
304 304 if compare or (at_version and not at_version == 'latest'):
305 305 c.allowed_to_change_status = False
306 306 c.allowed_to_update = False
307 307 c.allowed_to_merge = False
308 308 c.allowed_to_delete = False
309 309 c.allowed_to_comment = False
310 310 c.allowed_to_close = False
311 311 else:
312 312 can_change_status = PullRequestModel().check_user_change_status(
313 313 pull_request_at_ver, self._rhodecode_user)
314 314 c.allowed_to_change_status = can_change_status and not pr_closed
315 315
316 316 c.allowed_to_update = PullRequestModel().check_user_update(
317 317 pull_request_latest, self._rhodecode_user) and not pr_closed
318 318 c.allowed_to_merge = PullRequestModel().check_user_merge(
319 319 pull_request_latest, self._rhodecode_user) and not pr_closed
320 320 c.allowed_to_delete = PullRequestModel().check_user_delete(
321 321 pull_request_latest, self._rhodecode_user) and not pr_closed
322 322 c.allowed_to_comment = not pr_closed
323 323 c.allowed_to_close = c.allowed_to_merge and not pr_closed
324 324
325 325 c.forbid_adding_reviewers = False
326 326 c.forbid_author_to_review = False
327 327 c.forbid_commit_author_to_review = False
328 328
329 329 if pull_request_latest.reviewer_data and \
330 330 'rules' in pull_request_latest.reviewer_data:
331 331 rules = pull_request_latest.reviewer_data['rules'] or {}
332 332 try:
333 333 c.forbid_adding_reviewers = rules.get(
334 334 'forbid_adding_reviewers')
335 335 c.forbid_author_to_review = rules.get(
336 336 'forbid_author_to_review')
337 337 c.forbid_commit_author_to_review = rules.get(
338 338 'forbid_commit_author_to_review')
339 339 except Exception:
340 340 pass
341 341
342 342 # check merge capabilities
343 343 _merge_check = MergeCheck.validate(
344 344 pull_request_latest, auth_user=self._rhodecode_user,
345 345 translator=self.request.translate,
346 346 force_shadow_repo_refresh=force_refresh)
347 347 c.pr_merge_errors = _merge_check.error_details
348 348 c.pr_merge_possible = not _merge_check.failed
349 349 c.pr_merge_message = _merge_check.merge_msg
350 350
351 351 c.pr_merge_info = MergeCheck.get_merge_conditions(
352 352 pull_request_latest, translator=self.request.translate)
353 353
354 354 c.pull_request_review_status = _merge_check.review_status
355 355 if merge_checks:
356 356 self.request.override_renderer = \
357 357 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
358 358 return self._get_template_context(c)
359 359
360 360 comments_model = CommentsModel()
361 361
362 362 # reviewers and statuses
363 363 c.pull_request_reviewers = pull_request_at_ver.reviewers_statuses()
364 364 allowed_reviewers = [x[0].user_id for x in c.pull_request_reviewers]
365 365
366 366 # GENERAL COMMENTS with versions #
367 367 q = comments_model._all_general_comments_of_pull_request(pull_request_latest)
368 368 q = q.order_by(ChangesetComment.comment_id.asc())
369 369 general_comments = q
370 370
371 371 # pick comments we want to render at current version
372 372 c.comment_versions = comments_model.aggregate_comments(
373 373 general_comments, versions, c.at_version_num)
374 374 c.comments = c.comment_versions[c.at_version_num]['until']
375 375
376 376 # INLINE COMMENTS with versions #
377 377 q = comments_model._all_inline_comments_of_pull_request(pull_request_latest)
378 378 q = q.order_by(ChangesetComment.comment_id.asc())
379 379 inline_comments = q
380 380
381 381 c.inline_versions = comments_model.aggregate_comments(
382 382 inline_comments, versions, c.at_version_num, inline=True)
383 383
384 384 # inject latest version
385 385 latest_ver = PullRequest.get_pr_display_object(
386 386 pull_request_latest, pull_request_latest)
387 387
388 388 c.versions = versions + [latest_ver]
389 389
390 390 # if we use version, then do not show later comments
391 391 # than current version
392 392 display_inline_comments = collections.defaultdict(
393 393 lambda: collections.defaultdict(list))
394 394 for co in inline_comments:
395 395 if c.at_version_num:
396 396 # pick comments that are at least UPTO given version, so we
397 397 # don't render comments for higher version
398 398 should_render = co.pull_request_version_id and \
399 399 co.pull_request_version_id <= c.at_version_num
400 400 else:
401 401 # showing all, for 'latest'
402 402 should_render = True
403 403
404 404 if should_render:
405 405 display_inline_comments[co.f_path][co.line_no].append(co)
406 406
407 407 # load diff data into template context, if we use compare mode then
408 408 # diff is calculated based on changes between versions of PR
409 409
410 410 source_repo = pull_request_at_ver.source_repo
411 411 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
412 412
413 413 target_repo = pull_request_at_ver.target_repo
414 414 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
415 415
416 416 if compare:
417 417 # in compare switch the diff base to latest commit from prev version
418 418 target_ref_id = prev_pull_request_display_obj.revisions[0]
419 419
420 420 # despite opening commits for bookmarks/branches/tags, we always
421 421 # convert this to rev to prevent changes after bookmark or branch change
422 422 c.source_ref_type = 'rev'
423 423 c.source_ref = source_ref_id
424 424
425 425 c.target_ref_type = 'rev'
426 426 c.target_ref = target_ref_id
427 427
428 428 c.source_repo = source_repo
429 429 c.target_repo = target_repo
430 430
431 431 c.commit_ranges = []
432 432 source_commit = EmptyCommit()
433 433 target_commit = EmptyCommit()
434 434 c.missing_requirements = False
435 435
436 436 source_scm = source_repo.scm_instance()
437 437 target_scm = target_repo.scm_instance()
438 438
439 439 shadow_scm = None
440 440 try:
441 441 shadow_scm = pull_request_latest.get_shadow_repo()
442 442 except Exception:
443 443 log.debug('Failed to get shadow repo', exc_info=True)
444 444 # try first the existing source_repo, and then shadow
445 445 # repo if we can obtain one
446 446 commits_source_repo = source_scm or shadow_scm
447 447
448 448 c.commits_source_repo = commits_source_repo
449 449 c.ancestor = None # set it to None, to hide it from PR view
450 450
451 451 # empty version means latest, so we keep this to prevent
452 452 # double caching
453 453 version_normalized = version or 'latest'
454 454 from_version_normalized = from_version or 'latest'
455 455
456 456 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(
457 457 target_repo)
458 458 cache_file_path = diff_cache_exist(
459 459 cache_path, 'pull_request', pull_request_id, version_normalized,
460 460 from_version_normalized, source_ref_id, target_ref_id, c.fulldiff)
461 461
462 462 caching_enabled = self._is_diff_cache_enabled(c.target_repo)
463 463 force_recache = str2bool(self.request.GET.get('force_recache'))
464 464
465 465 cached_diff = None
466 466 if caching_enabled:
467 467 cached_diff = load_cached_diff(cache_file_path)
468 468
469 469 has_proper_commit_cache = (
470 470 cached_diff and cached_diff.get('commits')
471 471 and len(cached_diff.get('commits', [])) == 5
472 472 and cached_diff.get('commits')[0]
473 473 and cached_diff.get('commits')[3])
474 474 if not force_recache and has_proper_commit_cache:
475 475 diff_commit_cache = \
476 476 (ancestor_commit, commit_cache, missing_requirements,
477 477 source_commit, target_commit) = cached_diff['commits']
478 478 else:
479 479 diff_commit_cache = \
480 480 (ancestor_commit, commit_cache, missing_requirements,
481 481 source_commit, target_commit) = self.get_commits(
482 482 commits_source_repo,
483 483 pull_request_at_ver,
484 484 source_commit,
485 485 source_ref_id,
486 486 source_scm,
487 487 target_commit,
488 488 target_ref_id,
489 489 target_scm)
490 490
491 491 # register our commit range
492 492 for comm in commit_cache.values():
493 493 c.commit_ranges.append(comm)
494 494
495 495 c.missing_requirements = missing_requirements
496 496 c.ancestor_commit = ancestor_commit
497 497 c.statuses = source_repo.statuses(
498 498 [x.raw_id for x in c.commit_ranges])
499 499
500 500 # auto collapse if we have more than limit
501 501 collapse_limit = diffs.DiffProcessor._collapse_commits_over
502 502 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
503 503 c.compare_mode = compare
504 504
505 505 # diff_limit is the old behavior, will cut off the whole diff
506 506 # if the limit is applied otherwise will just hide the
507 507 # big files from the front-end
508 508 diff_limit = c.visual.cut_off_limit_diff
509 509 file_limit = c.visual.cut_off_limit_file
510 510
511 511 c.missing_commits = False
512 512 if (c.missing_requirements
513 513 or isinstance(source_commit, EmptyCommit)
514 514 or source_commit == target_commit):
515 515
516 516 c.missing_commits = True
517 517 else:
518 518 c.inline_comments = display_inline_comments
519 519
520 520 has_proper_diff_cache = cached_diff and cached_diff.get('commits')
521 521 if not force_recache and has_proper_diff_cache:
522 522 c.diffset = cached_diff['diff']
523 523 (ancestor_commit, commit_cache, missing_requirements,
524 524 source_commit, target_commit) = cached_diff['commits']
525 525 else:
526 526 c.diffset = self._get_diffset(
527 527 c.source_repo.repo_name, commits_source_repo,
528 528 source_ref_id, target_ref_id,
529 529 target_commit, source_commit,
530 530 diff_limit, file_limit, c.fulldiff)
531 531
532 532 # save cached diff
533 533 if caching_enabled:
534 534 cache_diff(cache_file_path, c.diffset, diff_commit_cache)
535 535
536 536 c.limited_diff = c.diffset.limited_diff
537 537
538 538 # calculate removed files that are bound to comments
539 539 comment_deleted_files = [
540 540 fname for fname in display_inline_comments
541 541 if fname not in c.diffset.file_stats]
542 542
543 543 c.deleted_files_comments = collections.defaultdict(dict)
544 544 for fname, per_line_comments in display_inline_comments.items():
545 545 if fname in comment_deleted_files:
546 546 c.deleted_files_comments[fname]['stats'] = 0
547 547 c.deleted_files_comments[fname]['comments'] = list()
548 548 for lno, comments in per_line_comments.items():
549 549 c.deleted_files_comments[fname]['comments'].extend(
550 550 comments)
551 551
552 552 # this is a hack to properly display links, when creating PR, the
553 553 # compare view and others uses different notation, and
554 554 # compare_commits.mako renders links based on the target_repo.
555 555 # We need to swap that here to generate it properly on the html side
556 556 c.target_repo = c.source_repo
557 557
558 558 c.commit_statuses = ChangesetStatus.STATUSES
559 559
560 560 c.show_version_changes = not pr_closed
561 561 if c.show_version_changes:
562 562 cur_obj = pull_request_at_ver
563 563 prev_obj = prev_pull_request_at_ver
564 564
565 565 old_commit_ids = prev_obj.revisions
566 566 new_commit_ids = cur_obj.revisions
567 567 commit_changes = PullRequestModel()._calculate_commit_id_changes(
568 568 old_commit_ids, new_commit_ids)
569 569 c.commit_changes_summary = commit_changes
570 570
571 571 # calculate the diff for commits between versions
572 572 c.commit_changes = []
573 573 mark = lambda cs, fw: list(
574 574 h.itertools.izip_longest([], cs, fillvalue=fw))
575 575 for c_type, raw_id in mark(commit_changes.added, 'a') \
576 576 + mark(commit_changes.removed, 'r') \
577 577 + mark(commit_changes.common, 'c'):
578 578
579 579 if raw_id in commit_cache:
580 580 commit = commit_cache[raw_id]
581 581 else:
582 582 try:
583 583 commit = commits_source_repo.get_commit(raw_id)
584 584 except CommitDoesNotExistError:
585 585 # in case we fail extracting still use "dummy" commit
586 586 # for display in commit diff
587 587 commit = h.AttributeDict(
588 588 {'raw_id': raw_id,
589 589 'message': 'EMPTY or MISSING COMMIT'})
590 590 c.commit_changes.append([c_type, commit])
591 591
592 592 # current user review statuses for each version
593 593 c.review_versions = {}
594 594 if self._rhodecode_user.user_id in allowed_reviewers:
595 595 for co in general_comments:
596 596 if co.author.user_id == self._rhodecode_user.user_id:
597 597 status = co.status_change
598 598 if status:
599 599 _ver_pr = status[0].comment.pull_request_version_id
600 600 c.review_versions[_ver_pr] = status[0]
601 601
602 602 return self._get_template_context(c)
603 603
604 604 def get_commits(
605 605 self, commits_source_repo, pull_request_at_ver, source_commit,
606 606 source_ref_id, source_scm, target_commit, target_ref_id, target_scm):
607 607 commit_cache = collections.OrderedDict()
608 608 missing_requirements = False
609 609 try:
610 610 pre_load = ["author", "branch", "date", "message"]
611 611 show_revs = pull_request_at_ver.revisions
612 612 for rev in show_revs:
613 613 comm = commits_source_repo.get_commit(
614 614 commit_id=rev, pre_load=pre_load)
615 615 commit_cache[comm.raw_id] = comm
616 616
617 617 # Order here matters, we first need to get target, and then
618 618 # the source
619 619 target_commit = commits_source_repo.get_commit(
620 620 commit_id=safe_str(target_ref_id))
621 621
622 622 source_commit = commits_source_repo.get_commit(
623 623 commit_id=safe_str(source_ref_id))
624 624 except CommitDoesNotExistError:
625 625 log.warning(
626 626 'Failed to get commit from `{}` repo'.format(
627 627 commits_source_repo), exc_info=True)
628 628 except RepositoryRequirementError:
629 629 log.warning(
630 630 'Failed to get all required data from repo', exc_info=True)
631 631 missing_requirements = True
632 632 ancestor_commit = None
633 633 try:
634 634 ancestor_id = source_scm.get_common_ancestor(
635 635 source_commit.raw_id, target_commit.raw_id, target_scm)
636 636 ancestor_commit = source_scm.get_commit(ancestor_id)
637 637 except Exception:
638 638 ancestor_commit = None
639 639 return ancestor_commit, commit_cache, missing_requirements, source_commit, target_commit
640 640
641 641 def assure_not_empty_repo(self):
642 642 _ = self.request.translate
643 643
644 644 try:
645 645 self.db_repo.scm_instance().get_commit()
646 646 except EmptyRepositoryError:
647 647 h.flash(h.literal(_('There are no commits yet')),
648 648 category='warning')
649 649 raise HTTPFound(
650 650 h.route_path('repo_summary', repo_name=self.db_repo.repo_name))
651 651
652 652 @LoginRequired()
653 653 @NotAnonymous()
654 654 @HasRepoPermissionAnyDecorator(
655 655 'repository.read', 'repository.write', 'repository.admin')
656 656 @view_config(
657 657 route_name='pullrequest_new', request_method='GET',
658 658 renderer='rhodecode:templates/pullrequests/pullrequest.mako')
659 659 def pull_request_new(self):
660 660 _ = self.request.translate
661 661 c = self.load_default_context()
662 662
663 663 self.assure_not_empty_repo()
664 664 source_repo = self.db_repo
665 665
666 666 commit_id = self.request.GET.get('commit')
667 667 branch_ref = self.request.GET.get('branch')
668 668 bookmark_ref = self.request.GET.get('bookmark')
669 669
670 670 try:
671 671 source_repo_data = PullRequestModel().generate_repo_data(
672 672 source_repo, commit_id=commit_id,
673 673 branch=branch_ref, bookmark=bookmark_ref,
674 674 translator=self.request.translate)
675 675 except CommitDoesNotExistError as e:
676 676 log.exception(e)
677 677 h.flash(_('Commit does not exist'), 'error')
678 678 raise HTTPFound(
679 679 h.route_path('pullrequest_new', repo_name=source_repo.repo_name))
680 680
681 681 default_target_repo = source_repo
682 682
683 683 if source_repo.parent:
684 684 parent_vcs_obj = source_repo.parent.scm_instance()
685 685 if parent_vcs_obj and not parent_vcs_obj.is_empty():
686 686 # change default if we have a parent repo
687 687 default_target_repo = source_repo.parent
688 688
689 689 target_repo_data = PullRequestModel().generate_repo_data(
690 690 default_target_repo, translator=self.request.translate)
691 691
692 692 selected_source_ref = source_repo_data['refs']['selected_ref']
693 693 title_source_ref = ''
694 694 if selected_source_ref:
695 695 title_source_ref = selected_source_ref.split(':', 2)[1]
696 696 c.default_title = PullRequestModel().generate_pullrequest_title(
697 697 source=source_repo.repo_name,
698 698 source_ref=title_source_ref,
699 699 target=default_target_repo.repo_name
700 700 )
701 701
702 702 c.default_repo_data = {
703 703 'source_repo_name': source_repo.repo_name,
704 704 'source_refs_json': json.dumps(source_repo_data),
705 705 'target_repo_name': default_target_repo.repo_name,
706 706 'target_refs_json': json.dumps(target_repo_data),
707 707 }
708 708 c.default_source_ref = selected_source_ref
709 709
710 710 return self._get_template_context(c)
711 711
712 712 @LoginRequired()
713 713 @NotAnonymous()
714 714 @HasRepoPermissionAnyDecorator(
715 715 'repository.read', 'repository.write', 'repository.admin')
716 716 @view_config(
717 717 route_name='pullrequest_repo_refs', request_method='GET',
718 718 renderer='json_ext', xhr=True)
719 719 def pull_request_repo_refs(self):
720 720 self.load_default_context()
721 721 target_repo_name = self.request.matchdict['target_repo_name']
722 722 repo = Repository.get_by_repo_name(target_repo_name)
723 723 if not repo:
724 724 raise HTTPNotFound()
725 725
726 726 target_perm = HasRepoPermissionAny(
727 727 'repository.read', 'repository.write', 'repository.admin')(
728 728 target_repo_name)
729 729 if not target_perm:
730 730 raise HTTPNotFound()
731 731
732 732 return PullRequestModel().generate_repo_data(
733 733 repo, translator=self.request.translate)
734 734
735 735 @LoginRequired()
736 736 @NotAnonymous()
737 737 @HasRepoPermissionAnyDecorator(
738 738 'repository.read', 'repository.write', 'repository.admin')
739 739 @view_config(
740 740 route_name='pullrequest_repo_destinations', request_method='GET',
741 741 renderer='json_ext', xhr=True)
742 742 def pull_request_repo_destinations(self):
743 743 _ = self.request.translate
744 744 filter_query = self.request.GET.get('query')
745 745
746 746 query = Repository.query() \
747 747 .order_by(func.length(Repository.repo_name)) \
748 748 .filter(
749 749 or_(Repository.repo_name == self.db_repo.repo_name,
750 750 Repository.fork_id == self.db_repo.repo_id))
751 751
752 752 if filter_query:
753 753 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
754 754 query = query.filter(
755 755 Repository.repo_name.ilike(ilike_expression))
756 756
757 757 add_parent = False
758 758 if self.db_repo.parent:
759 759 if filter_query in self.db_repo.parent.repo_name:
760 760 parent_vcs_obj = self.db_repo.parent.scm_instance()
761 761 if parent_vcs_obj and not parent_vcs_obj.is_empty():
762 762 add_parent = True
763 763
764 764 limit = 20 - 1 if add_parent else 20
765 765 all_repos = query.limit(limit).all()
766 766 if add_parent:
767 767 all_repos += [self.db_repo.parent]
768 768
769 769 repos = []
770 770 for obj in ScmModel().get_repos(all_repos):
771 771 repos.append({
772 772 'id': obj['name'],
773 773 'text': obj['name'],
774 774 'type': 'repo',
775 775 'repo_id': obj['dbrepo']['repo_id'],
776 776 'repo_type': obj['dbrepo']['repo_type'],
777 777 'private': obj['dbrepo']['private'],
778 778
779 779 })
780 780
781 781 data = {
782 782 'more': False,
783 783 'results': [{
784 784 'text': _('Repositories'),
785 785 'children': repos
786 786 }] if repos else []
787 787 }
788 788 return data
789 789
790 790 @LoginRequired()
791 791 @NotAnonymous()
792 792 @HasRepoPermissionAnyDecorator(
793 793 'repository.read', 'repository.write', 'repository.admin')
794 794 @CSRFRequired()
795 795 @view_config(
796 796 route_name='pullrequest_create', request_method='POST',
797 797 renderer=None)
798 798 def pull_request_create(self):
799 799 _ = self.request.translate
800 800 self.assure_not_empty_repo()
801 801 self.load_default_context()
802 802
803 803 controls = peppercorn.parse(self.request.POST.items())
804 804
805 805 try:
806 806 form = PullRequestForm(
807 807 self.request.translate, self.db_repo.repo_id)()
808 808 _form = form.to_python(controls)
809 809 except formencode.Invalid as errors:
810 810 if errors.error_dict.get('revisions'):
811 811 msg = 'Revisions: %s' % errors.error_dict['revisions']
812 812 elif errors.error_dict.get('pullrequest_title'):
813 813 msg = errors.error_dict.get('pullrequest_title')
814 814 else:
815 815 msg = _('Error creating pull request: {}').format(errors)
816 816 log.exception(msg)
817 817 h.flash(msg, 'error')
818 818
819 819 # would rather just go back to form ...
820 820 raise HTTPFound(
821 821 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
822 822
823 823 source_repo = _form['source_repo']
824 824 source_ref = _form['source_ref']
825 825 target_repo = _form['target_repo']
826 826 target_ref = _form['target_ref']
827 827 commit_ids = _form['revisions'][::-1]
828 828
829 829 # find the ancestor for this pr
830 830 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
831 831 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
832 832
833 833 # re-check permissions again here
834 834 # source_repo we must have read permissions
835 835
836 836 source_perm = HasRepoPermissionAny(
837 837 'repository.read',
838 838 'repository.write', 'repository.admin')(source_db_repo.repo_name)
839 839 if not source_perm:
840 840 msg = _('Not Enough permissions to source repo `{}`.'.format(
841 841 source_db_repo.repo_name))
842 842 h.flash(msg, category='error')
843 843 # copy the args back to redirect
844 844 org_query = self.request.GET.mixed()
845 845 raise HTTPFound(
846 846 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
847 847 _query=org_query))
848 848
849 849 # target repo we must have read permissions, and also later on
850 850 # we want to check branch permissions here
851 851 target_perm = HasRepoPermissionAny(
852 852 'repository.read',
853 853 'repository.write', 'repository.admin')(target_db_repo.repo_name)
854 854 if not target_perm:
855 855 msg = _('Not Enough permissions to target repo `{}`.'.format(
856 856 target_db_repo.repo_name))
857 857 h.flash(msg, category='error')
858 858 # copy the args back to redirect
859 859 org_query = self.request.GET.mixed()
860 860 raise HTTPFound(
861 861 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
862 862 _query=org_query))
863 863
864 864 source_scm = source_db_repo.scm_instance()
865 865 target_scm = target_db_repo.scm_instance()
866 866
867 867 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
868 868 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
869 869
870 870 ancestor = source_scm.get_common_ancestor(
871 871 source_commit.raw_id, target_commit.raw_id, target_scm)
872 872
873 873 # recalculate target ref based on ancestor
874 874 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
875 875 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
876 876
877 877 get_default_reviewers_data, validate_default_reviewers = \
878 878 PullRequestModel().get_reviewer_functions()
879 879
880 880 # recalculate reviewers logic, to make sure we can validate this
881 881 reviewer_rules = get_default_reviewers_data(
882 882 self._rhodecode_db_user, source_db_repo,
883 883 source_commit, target_db_repo, target_commit)
884 884
885 885 given_reviewers = _form['review_members']
886 886 reviewers = validate_default_reviewers(
887 887 given_reviewers, reviewer_rules)
888 888
889 889 pullrequest_title = _form['pullrequest_title']
890 890 title_source_ref = source_ref.split(':', 2)[1]
891 891 if not pullrequest_title:
892 892 pullrequest_title = PullRequestModel().generate_pullrequest_title(
893 893 source=source_repo,
894 894 source_ref=title_source_ref,
895 895 target=target_repo
896 896 )
897 897
898 898 description = _form['pullrequest_desc']
899 899 description_renderer = _form['description_renderer']
900 900
901 901 try:
902 902 pull_request = PullRequestModel().create(
903 903 created_by=self._rhodecode_user.user_id,
904 904 source_repo=source_repo,
905 905 source_ref=source_ref,
906 906 target_repo=target_repo,
907 907 target_ref=target_ref,
908 908 revisions=commit_ids,
909 909 reviewers=reviewers,
910 910 title=pullrequest_title,
911 911 description=description,
912 912 description_renderer=description_renderer,
913 913 reviewer_data=reviewer_rules,
914 914 auth_user=self._rhodecode_user
915 915 )
916 916 Session().commit()
917 917
918 918 h.flash(_('Successfully opened new pull request'),
919 919 category='success')
920 920 except Exception:
921 921 msg = _('Error occurred during creation of this pull request.')
922 922 log.exception(msg)
923 923 h.flash(msg, category='error')
924 924
925 925 # copy the args back to redirect
926 926 org_query = self.request.GET.mixed()
927 927 raise HTTPFound(
928 928 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
929 929 _query=org_query))
930 930
931 931 raise HTTPFound(
932 932 h.route_path('pullrequest_show', repo_name=target_repo,
933 933 pull_request_id=pull_request.pull_request_id))
934 934
935 935 @LoginRequired()
936 936 @NotAnonymous()
937 937 @HasRepoPermissionAnyDecorator(
938 938 'repository.read', 'repository.write', 'repository.admin')
939 939 @CSRFRequired()
940 940 @view_config(
941 941 route_name='pullrequest_update', request_method='POST',
942 942 renderer='json_ext')
943 943 def pull_request_update(self):
944 944 pull_request = PullRequest.get_or_404(
945 945 self.request.matchdict['pull_request_id'])
946 946 _ = self.request.translate
947 947
948 948 self.load_default_context()
949 949
950 950 if pull_request.is_closed():
951 951 log.debug('update: forbidden because pull request is closed')
952 952 msg = _(u'Cannot update closed pull requests.')
953 953 h.flash(msg, category='error')
954 954 return True
955 955
956 956 # only owner or admin can update it
957 957 allowed_to_update = PullRequestModel().check_user_update(
958 958 pull_request, self._rhodecode_user)
959 959 if allowed_to_update:
960 960 controls = peppercorn.parse(self.request.POST.items())
961 961
962 962 if 'review_members' in controls:
963 963 self._update_reviewers(
964 964 pull_request, controls['review_members'],
965 965 pull_request.reviewer_data)
966 966 elif str2bool(self.request.POST.get('update_commits', 'false')):
967 967 self._update_commits(pull_request)
968 968 elif str2bool(self.request.POST.get('edit_pull_request', 'false')):
969 969 self._edit_pull_request(pull_request)
970 970 else:
971 971 raise HTTPBadRequest()
972 972 return True
973 973 raise HTTPForbidden()
974 974
975 975 def _edit_pull_request(self, pull_request):
976 976 _ = self.request.translate
977 977
978 978 try:
979 979 PullRequestModel().edit(
980 980 pull_request,
981 981 self.request.POST.get('title'),
982 982 self.request.POST.get('description'),
983 983 self.request.POST.get('description_renderer'),
984 984 self._rhodecode_user)
985 985 except ValueError:
986 986 msg = _(u'Cannot update closed pull requests.')
987 987 h.flash(msg, category='error')
988 988 return
989 989 else:
990 990 Session().commit()
991 991
992 992 msg = _(u'Pull request title & description updated.')
993 993 h.flash(msg, category='success')
994 994 return
995 995
996 996 def _update_commits(self, pull_request):
997 997 _ = self.request.translate
998 998 resp = PullRequestModel().update_commits(pull_request)
999 999
1000 1000 if resp.executed:
1001 1001
1002 1002 if resp.target_changed and resp.source_changed:
1003 1003 changed = 'target and source repositories'
1004 1004 elif resp.target_changed and not resp.source_changed:
1005 1005 changed = 'target repository'
1006 1006 elif not resp.target_changed and resp.source_changed:
1007 1007 changed = 'source repository'
1008 1008 else:
1009 1009 changed = 'nothing'
1010 1010
1011 1011 msg = _(
1012 1012 u'Pull request updated to "{source_commit_id}" with '
1013 1013 u'{count_added} added, {count_removed} removed commits. '
1014 1014 u'Source of changes: {change_source}')
1015 1015 msg = msg.format(
1016 1016 source_commit_id=pull_request.source_ref_parts.commit_id,
1017 1017 count_added=len(resp.changes.added),
1018 1018 count_removed=len(resp.changes.removed),
1019 1019 change_source=changed)
1020 1020 h.flash(msg, category='success')
1021 1021
1022 1022 channel = '/repo${}$/pr/{}'.format(
1023 1023 pull_request.target_repo.repo_name,
1024 1024 pull_request.pull_request_id)
1025 1025 message = msg + (
1026 1026 ' - <a onclick="window.location.reload()">'
1027 1027 '<strong>{}</strong></a>'.format(_('Reload page')))
1028 1028 channelstream.post_message(
1029 1029 channel, message, self._rhodecode_user.username,
1030 1030 registry=self.request.registry)
1031 1031 else:
1032 1032 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
1033 1033 warning_reasons = [
1034 1034 UpdateFailureReason.NO_CHANGE,
1035 1035 UpdateFailureReason.WRONG_REF_TYPE,
1036 1036 ]
1037 1037 category = 'warning' if resp.reason in warning_reasons else 'error'
1038 1038 h.flash(msg, category=category)
1039 1039
1040 1040 @LoginRequired()
1041 1041 @NotAnonymous()
1042 1042 @HasRepoPermissionAnyDecorator(
1043 1043 'repository.read', 'repository.write', 'repository.admin')
1044 1044 @CSRFRequired()
1045 1045 @view_config(
1046 1046 route_name='pullrequest_merge', request_method='POST',
1047 1047 renderer='json_ext')
1048 1048 def pull_request_merge(self):
1049 1049 """
1050 1050 Merge will perform a server-side merge of the specified
1051 1051 pull request, if the pull request is approved and mergeable.
1052 1052 After successful merging, the pull request is automatically
1053 1053 closed, with a relevant comment.
1054 1054 """
1055 1055 pull_request = PullRequest.get_or_404(
1056 1056 self.request.matchdict['pull_request_id'])
1057 1057
1058 1058 self.load_default_context()
1059 1059 check = MergeCheck.validate(
1060 1060 pull_request, auth_user=self._rhodecode_user,
1061 1061 translator=self.request.translate)
1062 1062 merge_possible = not check.failed
1063 1063
1064 1064 for err_type, error_msg in check.errors:
1065 1065 h.flash(error_msg, category=err_type)
1066 1066
1067 1067 if merge_possible:
1068 1068 log.debug("Pre-conditions checked, trying to merge.")
1069 1069 extras = vcs_operation_context(
1070 1070 self.request.environ, repo_name=pull_request.target_repo.repo_name,
1071 1071 username=self._rhodecode_db_user.username, action='push',
1072 1072 scm=pull_request.target_repo.repo_type)
1073 1073 self._merge_pull_request(
1074 1074 pull_request, self._rhodecode_db_user, extras)
1075 1075 else:
1076 1076 log.debug("Pre-conditions failed, NOT merging.")
1077 1077
1078 1078 raise HTTPFound(
1079 1079 h.route_path('pullrequest_show',
1080 1080 repo_name=pull_request.target_repo.repo_name,
1081 1081 pull_request_id=pull_request.pull_request_id))
1082 1082
1083 1083 def _merge_pull_request(self, pull_request, user, extras):
1084 1084 _ = self.request.translate
1085 1085 merge_resp = PullRequestModel().merge_repo(pull_request, user, extras=extras)
1086 1086
1087 1087 if merge_resp.executed:
1088 1088 log.debug("The merge was successful, closing the pull request.")
1089 1089 PullRequestModel().close_pull_request(
1090 1090 pull_request.pull_request_id, user)
1091 1091 Session().commit()
1092 1092 msg = _('Pull request was successfully merged and closed.')
1093 1093 h.flash(msg, category='success')
1094 1094 else:
1095 1095 log.debug(
1096 1096 "The merge was not successful. Merge response: %s",
1097 1097 merge_resp)
1098 1098 msg = PullRequestModel().merge_status_message(
1099 1099 merge_resp.failure_reason)
1100 1100 h.flash(msg, category='error')
1101 1101
1102 1102 def _update_reviewers(self, pull_request, review_members, reviewer_rules):
1103 1103 _ = self.request.translate
1104 1104 get_default_reviewers_data, validate_default_reviewers = \
1105 1105 PullRequestModel().get_reviewer_functions()
1106 1106
1107 1107 try:
1108 1108 reviewers = validate_default_reviewers(review_members, reviewer_rules)
1109 1109 except ValueError as e:
1110 1110 log.error('Reviewers Validation: {}'.format(e))
1111 1111 h.flash(e, category='error')
1112 1112 return
1113 1113
1114 1114 PullRequestModel().update_reviewers(
1115 1115 pull_request, reviewers, self._rhodecode_user)
1116 1116 h.flash(_('Pull request reviewers updated.'), category='success')
1117 1117 Session().commit()
1118 1118
1119 1119 @LoginRequired()
1120 1120 @NotAnonymous()
1121 1121 @HasRepoPermissionAnyDecorator(
1122 1122 'repository.read', 'repository.write', 'repository.admin')
1123 1123 @CSRFRequired()
1124 1124 @view_config(
1125 1125 route_name='pullrequest_delete', request_method='POST',
1126 1126 renderer='json_ext')
1127 1127 def pull_request_delete(self):
1128 1128 _ = self.request.translate
1129 1129
1130 1130 pull_request = PullRequest.get_or_404(
1131 1131 self.request.matchdict['pull_request_id'])
1132 1132 self.load_default_context()
1133 1133
1134 1134 pr_closed = pull_request.is_closed()
1135 1135 allowed_to_delete = PullRequestModel().check_user_delete(
1136 1136 pull_request, self._rhodecode_user) and not pr_closed
1137 1137
1138 1138 # only owner can delete it !
1139 1139 if allowed_to_delete:
1140 1140 PullRequestModel().delete(pull_request, self._rhodecode_user)
1141 1141 Session().commit()
1142 1142 h.flash(_('Successfully deleted pull request'),
1143 1143 category='success')
1144 1144 raise HTTPFound(h.route_path('pullrequest_show_all',
1145 1145 repo_name=self.db_repo_name))
1146 1146
1147 1147 log.warning('user %s tried to delete pull request without access',
1148 1148 self._rhodecode_user)
1149 1149 raise HTTPNotFound()
1150 1150
1151 1151 @LoginRequired()
1152 1152 @NotAnonymous()
1153 1153 @HasRepoPermissionAnyDecorator(
1154 1154 'repository.read', 'repository.write', 'repository.admin')
1155 1155 @CSRFRequired()
1156 1156 @view_config(
1157 1157 route_name='pullrequest_comment_create', request_method='POST',
1158 1158 renderer='json_ext')
1159 1159 def pull_request_comment_create(self):
1160 1160 _ = self.request.translate
1161 1161
1162 1162 pull_request = PullRequest.get_or_404(
1163 1163 self.request.matchdict['pull_request_id'])
1164 1164 pull_request_id = pull_request.pull_request_id
1165 1165
1166 1166 if pull_request.is_closed():
1167 1167 log.debug('comment: forbidden because pull request is closed')
1168 1168 raise HTTPForbidden()
1169 1169
1170 1170 allowed_to_comment = PullRequestModel().check_user_comment(
1171 1171 pull_request, self._rhodecode_user)
1172 1172 if not allowed_to_comment:
1173 1173 log.debug(
1174 1174 'comment: forbidden because pull request is from forbidden repo')
1175 1175 raise HTTPForbidden()
1176 1176
1177 1177 c = self.load_default_context()
1178 1178
1179 1179 status = self.request.POST.get('changeset_status', None)
1180 1180 text = self.request.POST.get('text')
1181 1181 comment_type = self.request.POST.get('comment_type')
1182 1182 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
1183 1183 close_pull_request = self.request.POST.get('close_pull_request')
1184 1184
1185 1185 # the logic here should work like following, if we submit close
1186 1186 # pr comment, use `close_pull_request_with_comment` function
1187 1187 # else handle regular comment logic
1188 1188
1189 1189 if close_pull_request:
1190 1190 # only owner or admin or person with write permissions
1191 1191 allowed_to_close = PullRequestModel().check_user_update(
1192 1192 pull_request, self._rhodecode_user)
1193 1193 if not allowed_to_close:
1194 1194 log.debug('comment: forbidden because not allowed to close '
1195 1195 'pull request %s', pull_request_id)
1196 1196 raise HTTPForbidden()
1197 1197 comment, status = PullRequestModel().close_pull_request_with_comment(
1198 pull_request, self._rhodecode_user, self.db_repo, message=text)
1198 pull_request, self._rhodecode_user, self.db_repo, message=text,
1199 auth_user=self._rhodecode_user)
1199 1200 Session().flush()
1200 1201 events.trigger(
1201 1202 events.PullRequestCommentEvent(pull_request, comment))
1202 1203
1203 1204 else:
1204 1205 # regular comment case, could be inline, or one with status.
1205 1206 # for that one we check also permissions
1206 1207
1207 1208 allowed_to_change_status = PullRequestModel().check_user_change_status(
1208 1209 pull_request, self._rhodecode_user)
1209 1210
1210 1211 if status and allowed_to_change_status:
1211 1212 message = (_('Status change %(transition_icon)s %(status)s')
1212 1213 % {'transition_icon': '>',
1213 1214 'status': ChangesetStatus.get_status_lbl(status)})
1214 1215 text = text or message
1215 1216
1216 1217 comment = CommentsModel().create(
1217 1218 text=text,
1218 1219 repo=self.db_repo.repo_id,
1219 1220 user=self._rhodecode_user.user_id,
1220 1221 pull_request=pull_request,
1221 1222 f_path=self.request.POST.get('f_path'),
1222 1223 line_no=self.request.POST.get('line'),
1223 1224 status_change=(ChangesetStatus.get_status_lbl(status)
1224 1225 if status and allowed_to_change_status else None),
1225 1226 status_change_type=(status
1226 1227 if status and allowed_to_change_status else None),
1227 1228 comment_type=comment_type,
1228 1229 resolves_comment_id=resolves_comment_id,
1229 1230 auth_user=self._rhodecode_user
1230 1231 )
1231 1232
1232 1233 if allowed_to_change_status:
1233 1234 # calculate old status before we change it
1234 1235 old_calculated_status = pull_request.calculated_review_status()
1235 1236
1236 1237 # get status if set !
1237 1238 if status:
1238 1239 ChangesetStatusModel().set_status(
1239 1240 self.db_repo.repo_id,
1240 1241 status,
1241 1242 self._rhodecode_user.user_id,
1242 1243 comment,
1243 1244 pull_request=pull_request
1244 1245 )
1245 1246
1246 1247 Session().flush()
1247 1248 # this is somehow required to get access to some relationship
1248 1249 # loaded on comment
1249 1250 Session().refresh(comment)
1250 1251
1251 1252 events.trigger(
1252 1253 events.PullRequestCommentEvent(pull_request, comment))
1253 1254
1254 1255 # we now calculate the status of pull request, and based on that
1255 1256 # calculation we set the commits status
1256 1257 calculated_status = pull_request.calculated_review_status()
1257 1258 if old_calculated_status != calculated_status:
1258 1259 PullRequestModel()._trigger_pull_request_hook(
1259 1260 pull_request, self._rhodecode_user, 'review_status_change')
1260 1261
1261 1262 Session().commit()
1262 1263
1263 1264 data = {
1264 1265 'target_id': h.safeid(h.safe_unicode(
1265 1266 self.request.POST.get('f_path'))),
1266 1267 }
1267 1268 if comment:
1268 1269 c.co = comment
1269 1270 rendered_comment = render(
1270 1271 'rhodecode:templates/changeset/changeset_comment_block.mako',
1271 1272 self._get_template_context(c), self.request)
1272 1273
1273 1274 data.update(comment.get_dict())
1274 1275 data.update({'rendered_text': rendered_comment})
1275 1276
1276 1277 return data
1277 1278
1278 1279 @LoginRequired()
1279 1280 @NotAnonymous()
1280 1281 @HasRepoPermissionAnyDecorator(
1281 1282 'repository.read', 'repository.write', 'repository.admin')
1282 1283 @CSRFRequired()
1283 1284 @view_config(
1284 1285 route_name='pullrequest_comment_delete', request_method='POST',
1285 1286 renderer='json_ext')
1286 1287 def pull_request_comment_delete(self):
1287 1288 pull_request = PullRequest.get_or_404(
1288 1289 self.request.matchdict['pull_request_id'])
1289 1290
1290 1291 comment = ChangesetComment.get_or_404(
1291 1292 self.request.matchdict['comment_id'])
1292 1293 comment_id = comment.comment_id
1293 1294
1294 1295 if pull_request.is_closed():
1295 1296 log.debug('comment: forbidden because pull request is closed')
1296 1297 raise HTTPForbidden()
1297 1298
1298 1299 if not comment:
1299 1300 log.debug('Comment with id:%s not found, skipping', comment_id)
1300 1301 # comment already deleted in another call probably
1301 1302 return True
1302 1303
1303 1304 if comment.pull_request.is_closed():
1304 1305 # don't allow deleting comments on closed pull request
1305 1306 raise HTTPForbidden()
1306 1307
1307 1308 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1308 1309 super_admin = h.HasPermissionAny('hg.admin')()
1309 1310 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1310 1311 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1311 1312 comment_repo_admin = is_repo_admin and is_repo_comment
1312 1313
1313 1314 if super_admin or comment_owner or comment_repo_admin:
1314 1315 old_calculated_status = comment.pull_request.calculated_review_status()
1315 1316 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
1316 1317 Session().commit()
1317 1318 calculated_status = comment.pull_request.calculated_review_status()
1318 1319 if old_calculated_status != calculated_status:
1319 1320 PullRequestModel()._trigger_pull_request_hook(
1320 1321 comment.pull_request, self._rhodecode_user, 'review_status_change')
1321 1322 return True
1322 1323 else:
1323 1324 log.warning('No permissions for user %s to delete comment_id: %s',
1324 1325 self._rhodecode_db_user, comment_id)
1325 1326 raise HTTPNotFound()
@@ -1,1726 +1,1727 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 """
23 23 pull request model for RhodeCode
24 24 """
25 25
26 26
27 27 import json
28 28 import logging
29 29 import datetime
30 30 import urllib
31 31 import collections
32 32
33 33 from pyramid.threadlocal import get_current_request
34 34
35 35 from rhodecode import events
36 36 from rhodecode.translation import lazy_ugettext#, _
37 37 from rhodecode.lib import helpers as h, hooks_utils, diffs
38 38 from rhodecode.lib import audit_logger
39 39 from rhodecode.lib.compat import OrderedDict
40 40 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
41 41 from rhodecode.lib.markup_renderer import (
42 42 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
43 43 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
44 44 from rhodecode.lib.vcs.backends.base import (
45 45 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason)
46 46 from rhodecode.lib.vcs.conf import settings as vcs_settings
47 47 from rhodecode.lib.vcs.exceptions import (
48 48 CommitDoesNotExistError, EmptyRepositoryError)
49 49 from rhodecode.model import BaseModel
50 50 from rhodecode.model.changeset_status import ChangesetStatusModel
51 51 from rhodecode.model.comment import CommentsModel
52 52 from rhodecode.model.db import (
53 53 or_, PullRequest, PullRequestReviewers, ChangesetStatus,
54 54 PullRequestVersion, ChangesetComment, Repository, RepoReviewRule)
55 55 from rhodecode.model.meta import Session
56 56 from rhodecode.model.notification import NotificationModel, \
57 57 EmailNotificationModel
58 58 from rhodecode.model.scm import ScmModel
59 59 from rhodecode.model.settings import VcsSettingsModel
60 60
61 61
62 62 log = logging.getLogger(__name__)
63 63
64 64
65 65 # Data structure to hold the response data when updating commits during a pull
66 66 # request update.
67 67 UpdateResponse = collections.namedtuple('UpdateResponse', [
68 68 'executed', 'reason', 'new', 'old', 'changes',
69 69 'source_changed', 'target_changed'])
70 70
71 71
72 72 class PullRequestModel(BaseModel):
73 73
74 74 cls = PullRequest
75 75
76 76 DIFF_CONTEXT = 3
77 77
78 78 MERGE_STATUS_MESSAGES = {
79 79 MergeFailureReason.NONE: lazy_ugettext(
80 80 'This pull request can be automatically merged.'),
81 81 MergeFailureReason.UNKNOWN: lazy_ugettext(
82 82 'This pull request cannot be merged because of an unhandled'
83 83 ' exception.'),
84 84 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
85 85 'This pull request cannot be merged because of merge conflicts.'),
86 86 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
87 87 'This pull request could not be merged because push to target'
88 88 ' failed.'),
89 89 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
90 90 'This pull request cannot be merged because the target is not a'
91 91 ' head.'),
92 92 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
93 93 'This pull request cannot be merged because the source contains'
94 94 ' more branches than the target.'),
95 95 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
96 96 'This pull request cannot be merged because the target has'
97 97 ' multiple heads.'),
98 98 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
99 99 'This pull request cannot be merged because the target repository'
100 100 ' is locked.'),
101 101 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
102 102 'This pull request cannot be merged because the target or the '
103 103 'source reference is missing.'),
104 104 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
105 105 'This pull request cannot be merged because the target '
106 106 'reference is missing.'),
107 107 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
108 108 'This pull request cannot be merged because the source '
109 109 'reference is missing.'),
110 110 MergeFailureReason.SUBREPO_MERGE_FAILED: lazy_ugettext(
111 111 'This pull request cannot be merged because of conflicts related '
112 112 'to sub repositories.'),
113 113 }
114 114
115 115 UPDATE_STATUS_MESSAGES = {
116 116 UpdateFailureReason.NONE: lazy_ugettext(
117 117 'Pull request update successful.'),
118 118 UpdateFailureReason.UNKNOWN: lazy_ugettext(
119 119 'Pull request update failed because of an unknown error.'),
120 120 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
121 121 'No update needed because the source and target have not changed.'),
122 122 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
123 123 'Pull request cannot be updated because the reference type is '
124 124 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
125 125 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
126 126 'This pull request cannot be updated because the target '
127 127 'reference is missing.'),
128 128 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
129 129 'This pull request cannot be updated because the source '
130 130 'reference is missing.'),
131 131 }
132 132
133 133 def __get_pull_request(self, pull_request):
134 134 return self._get_instance((
135 135 PullRequest, PullRequestVersion), pull_request)
136 136
137 137 def _check_perms(self, perms, pull_request, user, api=False):
138 138 if not api:
139 139 return h.HasRepoPermissionAny(*perms)(
140 140 user=user, repo_name=pull_request.target_repo.repo_name)
141 141 else:
142 142 return h.HasRepoPermissionAnyApi(*perms)(
143 143 user=user, repo_name=pull_request.target_repo.repo_name)
144 144
145 145 def check_user_read(self, pull_request, user, api=False):
146 146 _perms = ('repository.admin', 'repository.write', 'repository.read',)
147 147 return self._check_perms(_perms, pull_request, user, api)
148 148
149 149 def check_user_merge(self, pull_request, user, api=False):
150 150 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
151 151 return self._check_perms(_perms, pull_request, user, api)
152 152
153 153 def check_user_update(self, pull_request, user, api=False):
154 154 owner = user.user_id == pull_request.user_id
155 155 return self.check_user_merge(pull_request, user, api) or owner
156 156
157 157 def check_user_delete(self, pull_request, user):
158 158 owner = user.user_id == pull_request.user_id
159 159 _perms = ('repository.admin',)
160 160 return self._check_perms(_perms, pull_request, user) or owner
161 161
162 162 def check_user_change_status(self, pull_request, user, api=False):
163 163 reviewer = user.user_id in [x.user_id for x in
164 164 pull_request.reviewers]
165 165 return self.check_user_update(pull_request, user, api) or reviewer
166 166
167 167 def check_user_comment(self, pull_request, user):
168 168 owner = user.user_id == pull_request.user_id
169 169 return self.check_user_read(pull_request, user) or owner
170 170
171 171 def get(self, pull_request):
172 172 return self.__get_pull_request(pull_request)
173 173
174 174 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
175 175 opened_by=None, order_by=None,
176 176 order_dir='desc'):
177 177 repo = None
178 178 if repo_name:
179 179 repo = self._get_repo(repo_name)
180 180
181 181 q = PullRequest.query()
182 182
183 183 # source or target
184 184 if repo and source:
185 185 q = q.filter(PullRequest.source_repo == repo)
186 186 elif repo:
187 187 q = q.filter(PullRequest.target_repo == repo)
188 188
189 189 # closed,opened
190 190 if statuses:
191 191 q = q.filter(PullRequest.status.in_(statuses))
192 192
193 193 # opened by filter
194 194 if opened_by:
195 195 q = q.filter(PullRequest.user_id.in_(opened_by))
196 196
197 197 if order_by:
198 198 order_map = {
199 199 'name_raw': PullRequest.pull_request_id,
200 200 'title': PullRequest.title,
201 201 'updated_on_raw': PullRequest.updated_on,
202 202 'target_repo': PullRequest.target_repo_id
203 203 }
204 204 if order_dir == 'asc':
205 205 q = q.order_by(order_map[order_by].asc())
206 206 else:
207 207 q = q.order_by(order_map[order_by].desc())
208 208
209 209 return q
210 210
211 211 def count_all(self, repo_name, source=False, statuses=None,
212 212 opened_by=None):
213 213 """
214 214 Count the number of pull requests for a specific repository.
215 215
216 216 :param repo_name: target or source repo
217 217 :param source: boolean flag to specify if repo_name refers to source
218 218 :param statuses: list of pull request statuses
219 219 :param opened_by: author user of the pull request
220 220 :returns: int number of pull requests
221 221 """
222 222 q = self._prepare_get_all_query(
223 223 repo_name, source=source, statuses=statuses, opened_by=opened_by)
224 224
225 225 return q.count()
226 226
227 227 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
228 228 offset=0, length=None, order_by=None, order_dir='desc'):
229 229 """
230 230 Get all pull requests for a specific repository.
231 231
232 232 :param repo_name: target or source repo
233 233 :param source: boolean flag to specify if repo_name refers to source
234 234 :param statuses: list of pull request statuses
235 235 :param opened_by: author user of the pull request
236 236 :param offset: pagination offset
237 237 :param length: length of returned list
238 238 :param order_by: order of the returned list
239 239 :param order_dir: 'asc' or 'desc' ordering direction
240 240 :returns: list of pull requests
241 241 """
242 242 q = self._prepare_get_all_query(
243 243 repo_name, source=source, statuses=statuses, opened_by=opened_by,
244 244 order_by=order_by, order_dir=order_dir)
245 245
246 246 if length:
247 247 pull_requests = q.limit(length).offset(offset).all()
248 248 else:
249 249 pull_requests = q.all()
250 250
251 251 return pull_requests
252 252
253 253 def count_awaiting_review(self, repo_name, source=False, statuses=None,
254 254 opened_by=None):
255 255 """
256 256 Count the number of pull requests for a specific repository that are
257 257 awaiting review.
258 258
259 259 :param repo_name: target or source repo
260 260 :param source: boolean flag to specify if repo_name refers to source
261 261 :param statuses: list of pull request statuses
262 262 :param opened_by: author user of the pull request
263 263 :returns: int number of pull requests
264 264 """
265 265 pull_requests = self.get_awaiting_review(
266 266 repo_name, source=source, statuses=statuses, opened_by=opened_by)
267 267
268 268 return len(pull_requests)
269 269
270 270 def get_awaiting_review(self, repo_name, source=False, statuses=None,
271 271 opened_by=None, offset=0, length=None,
272 272 order_by=None, order_dir='desc'):
273 273 """
274 274 Get all pull requests for a specific repository that are awaiting
275 275 review.
276 276
277 277 :param repo_name: target or source repo
278 278 :param source: boolean flag to specify if repo_name refers to source
279 279 :param statuses: list of pull request statuses
280 280 :param opened_by: author user of the pull request
281 281 :param offset: pagination offset
282 282 :param length: length of returned list
283 283 :param order_by: order of the returned list
284 284 :param order_dir: 'asc' or 'desc' ordering direction
285 285 :returns: list of pull requests
286 286 """
287 287 pull_requests = self.get_all(
288 288 repo_name, source=source, statuses=statuses, opened_by=opened_by,
289 289 order_by=order_by, order_dir=order_dir)
290 290
291 291 _filtered_pull_requests = []
292 292 for pr in pull_requests:
293 293 status = pr.calculated_review_status()
294 294 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
295 295 ChangesetStatus.STATUS_UNDER_REVIEW]:
296 296 _filtered_pull_requests.append(pr)
297 297 if length:
298 298 return _filtered_pull_requests[offset:offset+length]
299 299 else:
300 300 return _filtered_pull_requests
301 301
302 302 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
303 303 opened_by=None, user_id=None):
304 304 """
305 305 Count the number of pull requests for a specific repository that are
306 306 awaiting review from a specific user.
307 307
308 308 :param repo_name: target or source repo
309 309 :param source: boolean flag to specify if repo_name refers to source
310 310 :param statuses: list of pull request statuses
311 311 :param opened_by: author user of the pull request
312 312 :param user_id: reviewer user of the pull request
313 313 :returns: int number of pull requests
314 314 """
315 315 pull_requests = self.get_awaiting_my_review(
316 316 repo_name, source=source, statuses=statuses, opened_by=opened_by,
317 317 user_id=user_id)
318 318
319 319 return len(pull_requests)
320 320
321 321 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
322 322 opened_by=None, user_id=None, offset=0,
323 323 length=None, order_by=None, order_dir='desc'):
324 324 """
325 325 Get all pull requests for a specific repository that are awaiting
326 326 review from a specific user.
327 327
328 328 :param repo_name: target or source repo
329 329 :param source: boolean flag to specify if repo_name refers to source
330 330 :param statuses: list of pull request statuses
331 331 :param opened_by: author user of the pull request
332 332 :param user_id: reviewer user of the pull request
333 333 :param offset: pagination offset
334 334 :param length: length of returned list
335 335 :param order_by: order of the returned list
336 336 :param order_dir: 'asc' or 'desc' ordering direction
337 337 :returns: list of pull requests
338 338 """
339 339 pull_requests = self.get_all(
340 340 repo_name, source=source, statuses=statuses, opened_by=opened_by,
341 341 order_by=order_by, order_dir=order_dir)
342 342
343 343 _my = PullRequestModel().get_not_reviewed(user_id)
344 344 my_participation = []
345 345 for pr in pull_requests:
346 346 if pr in _my:
347 347 my_participation.append(pr)
348 348 _filtered_pull_requests = my_participation
349 349 if length:
350 350 return _filtered_pull_requests[offset:offset+length]
351 351 else:
352 352 return _filtered_pull_requests
353 353
354 354 def get_not_reviewed(self, user_id):
355 355 return [
356 356 x.pull_request for x in PullRequestReviewers.query().filter(
357 357 PullRequestReviewers.user_id == user_id).all()
358 358 ]
359 359
360 360 def _prepare_participating_query(self, user_id=None, statuses=None,
361 361 order_by=None, order_dir='desc'):
362 362 q = PullRequest.query()
363 363 if user_id:
364 364 reviewers_subquery = Session().query(
365 365 PullRequestReviewers.pull_request_id).filter(
366 366 PullRequestReviewers.user_id == user_id).subquery()
367 367 user_filter = or_(
368 368 PullRequest.user_id == user_id,
369 369 PullRequest.pull_request_id.in_(reviewers_subquery)
370 370 )
371 371 q = PullRequest.query().filter(user_filter)
372 372
373 373 # closed,opened
374 374 if statuses:
375 375 q = q.filter(PullRequest.status.in_(statuses))
376 376
377 377 if order_by:
378 378 order_map = {
379 379 'name_raw': PullRequest.pull_request_id,
380 380 'title': PullRequest.title,
381 381 'updated_on_raw': PullRequest.updated_on,
382 382 'target_repo': PullRequest.target_repo_id
383 383 }
384 384 if order_dir == 'asc':
385 385 q = q.order_by(order_map[order_by].asc())
386 386 else:
387 387 q = q.order_by(order_map[order_by].desc())
388 388
389 389 return q
390 390
391 391 def count_im_participating_in(self, user_id=None, statuses=None):
392 392 q = self._prepare_participating_query(user_id, statuses=statuses)
393 393 return q.count()
394 394
395 395 def get_im_participating_in(
396 396 self, user_id=None, statuses=None, offset=0,
397 397 length=None, order_by=None, order_dir='desc'):
398 398 """
399 399 Get all Pull requests that i'm participating in, or i have opened
400 400 """
401 401
402 402 q = self._prepare_participating_query(
403 403 user_id, statuses=statuses, order_by=order_by,
404 404 order_dir=order_dir)
405 405
406 406 if length:
407 407 pull_requests = q.limit(length).offset(offset).all()
408 408 else:
409 409 pull_requests = q.all()
410 410
411 411 return pull_requests
412 412
413 413 def get_versions(self, pull_request):
414 414 """
415 415 returns version of pull request sorted by ID descending
416 416 """
417 417 return PullRequestVersion.query()\
418 418 .filter(PullRequestVersion.pull_request == pull_request)\
419 419 .order_by(PullRequestVersion.pull_request_version_id.asc())\
420 420 .all()
421 421
422 422 def get_pr_version(self, pull_request_id, version=None):
423 423 at_version = None
424 424
425 425 if version and version == 'latest':
426 426 pull_request_ver = PullRequest.get(pull_request_id)
427 427 pull_request_obj = pull_request_ver
428 428 _org_pull_request_obj = pull_request_obj
429 429 at_version = 'latest'
430 430 elif version:
431 431 pull_request_ver = PullRequestVersion.get_or_404(version)
432 432 pull_request_obj = pull_request_ver
433 433 _org_pull_request_obj = pull_request_ver.pull_request
434 434 at_version = pull_request_ver.pull_request_version_id
435 435 else:
436 436 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
437 437 pull_request_id)
438 438
439 439 pull_request_display_obj = PullRequest.get_pr_display_object(
440 440 pull_request_obj, _org_pull_request_obj)
441 441
442 442 return _org_pull_request_obj, pull_request_obj, \
443 443 pull_request_display_obj, at_version
444 444
445 445 def create(self, created_by, source_repo, source_ref, target_repo,
446 446 target_ref, revisions, reviewers, title, description=None,
447 447 description_renderer=None,
448 448 reviewer_data=None, translator=None, auth_user=None):
449 449 translator = translator or get_current_request().translate
450 450
451 451 created_by_user = self._get_user(created_by)
452 452 auth_user = auth_user or created_by_user.AuthUser()
453 453 source_repo = self._get_repo(source_repo)
454 454 target_repo = self._get_repo(target_repo)
455 455
456 456 pull_request = PullRequest()
457 457 pull_request.source_repo = source_repo
458 458 pull_request.source_ref = source_ref
459 459 pull_request.target_repo = target_repo
460 460 pull_request.target_ref = target_ref
461 461 pull_request.revisions = revisions
462 462 pull_request.title = title
463 463 pull_request.description = description
464 464 pull_request.description_renderer = description_renderer
465 465 pull_request.author = created_by_user
466 466 pull_request.reviewer_data = reviewer_data
467 467
468 468 Session().add(pull_request)
469 469 Session().flush()
470 470
471 471 reviewer_ids = set()
472 472 # members / reviewers
473 473 for reviewer_object in reviewers:
474 474 user_id, reasons, mandatory, rules = reviewer_object
475 475 user = self._get_user(user_id)
476 476
477 477 # skip duplicates
478 478 if user.user_id in reviewer_ids:
479 479 continue
480 480
481 481 reviewer_ids.add(user.user_id)
482 482
483 483 reviewer = PullRequestReviewers()
484 484 reviewer.user = user
485 485 reviewer.pull_request = pull_request
486 486 reviewer.reasons = reasons
487 487 reviewer.mandatory = mandatory
488 488
489 489 # NOTE(marcink): pick only first rule for now
490 490 rule_id = list(rules)[0] if rules else None
491 491 rule = RepoReviewRule.get(rule_id) if rule_id else None
492 492 if rule:
493 493 review_group = rule.user_group_vote_rule(user_id)
494 494 # we check if this particular reviewer is member of a voting group
495 495 if review_group:
496 496 # NOTE(marcink):
497 497 # can be that user is member of more but we pick the first same,
498 498 # same as default reviewers algo
499 499 review_group = review_group[0]
500 500
501 501 rule_data = {
502 502 'rule_name':
503 503 rule.review_rule_name,
504 504 'rule_user_group_entry_id':
505 505 review_group.repo_review_rule_users_group_id,
506 506 'rule_user_group_name':
507 507 review_group.users_group.users_group_name,
508 508 'rule_user_group_members':
509 509 [x.user.username for x in review_group.users_group.members],
510 510 'rule_user_group_members_id':
511 511 [x.user.user_id for x in review_group.users_group.members],
512 512 }
513 513 # e.g {'vote_rule': -1, 'mandatory': True}
514 514 rule_data.update(review_group.rule_data())
515 515
516 516 reviewer.rule_data = rule_data
517 517
518 518 Session().add(reviewer)
519 519 Session().flush()
520 520
521 521 # Set approval status to "Under Review" for all commits which are
522 522 # part of this pull request.
523 523 ChangesetStatusModel().set_status(
524 524 repo=target_repo,
525 525 status=ChangesetStatus.STATUS_UNDER_REVIEW,
526 526 user=created_by_user,
527 527 pull_request=pull_request
528 528 )
529 529 # we commit early at this point. This has to do with a fact
530 530 # that before queries do some row-locking. And because of that
531 531 # we need to commit and finish transation before below validate call
532 532 # that for large repos could be long resulting in long row locks
533 533 Session().commit()
534 534
535 535 # prepare workspace, and run initial merge simulation
536 536 MergeCheck.validate(
537 537 pull_request, auth_user=auth_user, translator=translator)
538 538
539 539 self.notify_reviewers(pull_request, reviewer_ids)
540 540 self._trigger_pull_request_hook(
541 541 pull_request, created_by_user, 'create')
542 542
543 543 creation_data = pull_request.get_api_data(with_merge_state=False)
544 544 self._log_audit_action(
545 545 'repo.pull_request.create', {'data': creation_data},
546 546 auth_user, pull_request)
547 547
548 548 return pull_request
549 549
550 550 def _trigger_pull_request_hook(self, pull_request, user, action):
551 551 pull_request = self.__get_pull_request(pull_request)
552 552 target_scm = pull_request.target_repo.scm_instance()
553 553 if action == 'create':
554 554 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
555 555 elif action == 'merge':
556 556 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
557 557 elif action == 'close':
558 558 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
559 559 elif action == 'review_status_change':
560 560 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
561 561 elif action == 'update':
562 562 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
563 563 else:
564 564 return
565 565
566 566 trigger_hook(
567 567 username=user.username,
568 568 repo_name=pull_request.target_repo.repo_name,
569 569 repo_alias=target_scm.alias,
570 570 pull_request=pull_request)
571 571
572 572 def _get_commit_ids(self, pull_request):
573 573 """
574 574 Return the commit ids of the merged pull request.
575 575
576 576 This method is not dealing correctly yet with the lack of autoupdates
577 577 nor with the implicit target updates.
578 578 For example: if a commit in the source repo is already in the target it
579 579 will be reported anyways.
580 580 """
581 581 merge_rev = pull_request.merge_rev
582 582 if merge_rev is None:
583 583 raise ValueError('This pull request was not merged yet')
584 584
585 585 commit_ids = list(pull_request.revisions)
586 586 if merge_rev not in commit_ids:
587 587 commit_ids.append(merge_rev)
588 588
589 589 return commit_ids
590 590
591 591 def merge_repo(self, pull_request, user, extras):
592 592 log.debug("Merging pull request %s", pull_request.pull_request_id)
593 593 merge_state = self._merge_pull_request(pull_request, user, extras)
594 594 if merge_state.executed:
595 595 log.debug(
596 596 "Merge was successful, updating the pull request comments.")
597 597 self._comment_and_close_pr(pull_request, user, merge_state)
598 598
599 599 self._log_audit_action(
600 600 'repo.pull_request.merge',
601 601 {'merge_state': merge_state.__dict__},
602 602 user, pull_request)
603 603
604 604 else:
605 605 log.warn("Merge failed, not updating the pull request.")
606 606 return merge_state
607 607
608 608 def _merge_pull_request(self, pull_request, user, extras, merge_msg=None):
609 609 target_vcs = pull_request.target_repo.scm_instance()
610 610 source_vcs = pull_request.source_repo.scm_instance()
611 611 target_ref = self._refresh_reference(
612 612 pull_request.target_ref_parts, target_vcs)
613 613
614 614 message = merge_msg or (
615 615 'Merge pull request #%(pr_id)s from '
616 616 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
617 617 'pr_id': pull_request.pull_request_id,
618 618 'source_repo': source_vcs.name,
619 619 'source_ref_name': pull_request.source_ref_parts.name,
620 620 'pr_title': pull_request.title
621 621 }
622 622
623 623 workspace_id = self._workspace_id(pull_request)
624 624 repo_id = pull_request.target_repo.repo_id
625 625 use_rebase = self._use_rebase_for_merging(pull_request)
626 626 close_branch = self._close_branch_before_merging(pull_request)
627 627
628 628 callback_daemon, extras = prepare_callback_daemon(
629 629 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
630 630 host=vcs_settings.HOOKS_HOST,
631 631 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
632 632
633 633 with callback_daemon:
634 634 # TODO: johbo: Implement a clean way to run a config_override
635 635 # for a single call.
636 636 target_vcs.config.set(
637 637 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
638 638 merge_state = target_vcs.merge(
639 639 repo_id, workspace_id, target_ref, source_vcs,
640 640 pull_request.source_ref_parts,
641 641 user_name=user.username, user_email=user.email,
642 642 message=message, use_rebase=use_rebase,
643 643 close_branch=close_branch)
644 644 return merge_state
645 645
646 646 def _comment_and_close_pr(self, pull_request, user, merge_state, close_msg=None):
647 647 pull_request.merge_rev = merge_state.merge_ref.commit_id
648 648 pull_request.updated_on = datetime.datetime.now()
649 649 close_msg = close_msg or 'Pull request merged and closed'
650 650
651 651 CommentsModel().create(
652 652 text=safe_unicode(close_msg),
653 653 repo=pull_request.target_repo.repo_id,
654 654 user=user.user_id,
655 655 pull_request=pull_request.pull_request_id,
656 656 f_path=None,
657 657 line_no=None,
658 658 closing_pr=True
659 659 )
660 660
661 661 Session().add(pull_request)
662 662 Session().flush()
663 663 # TODO: paris: replace invalidation with less radical solution
664 664 ScmModel().mark_for_invalidation(
665 665 pull_request.target_repo.repo_name)
666 666 self._trigger_pull_request_hook(pull_request, user, 'merge')
667 667
668 668 def has_valid_update_type(self, pull_request):
669 669 source_ref_type = pull_request.source_ref_parts.type
670 670 return source_ref_type in ['book', 'branch', 'tag']
671 671
672 672 def update_commits(self, pull_request):
673 673 """
674 674 Get the updated list of commits for the pull request
675 675 and return the new pull request version and the list
676 676 of commits processed by this update action
677 677 """
678 678 pull_request = self.__get_pull_request(pull_request)
679 679 source_ref_type = pull_request.source_ref_parts.type
680 680 source_ref_name = pull_request.source_ref_parts.name
681 681 source_ref_id = pull_request.source_ref_parts.commit_id
682 682
683 683 target_ref_type = pull_request.target_ref_parts.type
684 684 target_ref_name = pull_request.target_ref_parts.name
685 685 target_ref_id = pull_request.target_ref_parts.commit_id
686 686
687 687 if not self.has_valid_update_type(pull_request):
688 688 log.debug(
689 689 "Skipping update of pull request %s due to ref type: %s",
690 690 pull_request, source_ref_type)
691 691 return UpdateResponse(
692 692 executed=False,
693 693 reason=UpdateFailureReason.WRONG_REF_TYPE,
694 694 old=pull_request, new=None, changes=None,
695 695 source_changed=False, target_changed=False)
696 696
697 697 # source repo
698 698 source_repo = pull_request.source_repo.scm_instance()
699 699 try:
700 700 source_commit = source_repo.get_commit(commit_id=source_ref_name)
701 701 except CommitDoesNotExistError:
702 702 return UpdateResponse(
703 703 executed=False,
704 704 reason=UpdateFailureReason.MISSING_SOURCE_REF,
705 705 old=pull_request, new=None, changes=None,
706 706 source_changed=False, target_changed=False)
707 707
708 708 source_changed = source_ref_id != source_commit.raw_id
709 709
710 710 # target repo
711 711 target_repo = pull_request.target_repo.scm_instance()
712 712 try:
713 713 target_commit = target_repo.get_commit(commit_id=target_ref_name)
714 714 except CommitDoesNotExistError:
715 715 return UpdateResponse(
716 716 executed=False,
717 717 reason=UpdateFailureReason.MISSING_TARGET_REF,
718 718 old=pull_request, new=None, changes=None,
719 719 source_changed=False, target_changed=False)
720 720 target_changed = target_ref_id != target_commit.raw_id
721 721
722 722 if not (source_changed or target_changed):
723 723 log.debug("Nothing changed in pull request %s", pull_request)
724 724 return UpdateResponse(
725 725 executed=False,
726 726 reason=UpdateFailureReason.NO_CHANGE,
727 727 old=pull_request, new=None, changes=None,
728 728 source_changed=target_changed, target_changed=source_changed)
729 729
730 730 change_in_found = 'target repo' if target_changed else 'source repo'
731 731 log.debug('Updating pull request because of change in %s detected',
732 732 change_in_found)
733 733
734 734 # Finally there is a need for an update, in case of source change
735 735 # we create a new version, else just an update
736 736 if source_changed:
737 737 pull_request_version = self._create_version_from_snapshot(pull_request)
738 738 self._link_comments_to_version(pull_request_version)
739 739 else:
740 740 try:
741 741 ver = pull_request.versions[-1]
742 742 except IndexError:
743 743 ver = None
744 744
745 745 pull_request.pull_request_version_id = \
746 746 ver.pull_request_version_id if ver else None
747 747 pull_request_version = pull_request
748 748
749 749 try:
750 750 if target_ref_type in ('tag', 'branch', 'book'):
751 751 target_commit = target_repo.get_commit(target_ref_name)
752 752 else:
753 753 target_commit = target_repo.get_commit(target_ref_id)
754 754 except CommitDoesNotExistError:
755 755 return UpdateResponse(
756 756 executed=False,
757 757 reason=UpdateFailureReason.MISSING_TARGET_REF,
758 758 old=pull_request, new=None, changes=None,
759 759 source_changed=source_changed, target_changed=target_changed)
760 760
761 761 # re-compute commit ids
762 762 old_commit_ids = pull_request.revisions
763 763 pre_load = ["author", "branch", "date", "message"]
764 764 commit_ranges = target_repo.compare(
765 765 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
766 766 pre_load=pre_load)
767 767
768 768 ancestor = target_repo.get_common_ancestor(
769 769 target_commit.raw_id, source_commit.raw_id, source_repo)
770 770
771 771 pull_request.source_ref = '%s:%s:%s' % (
772 772 source_ref_type, source_ref_name, source_commit.raw_id)
773 773 pull_request.target_ref = '%s:%s:%s' % (
774 774 target_ref_type, target_ref_name, ancestor)
775 775
776 776 pull_request.revisions = [
777 777 commit.raw_id for commit in reversed(commit_ranges)]
778 778 pull_request.updated_on = datetime.datetime.now()
779 779 Session().add(pull_request)
780 780 new_commit_ids = pull_request.revisions
781 781
782 782 old_diff_data, new_diff_data = self._generate_update_diffs(
783 783 pull_request, pull_request_version)
784 784
785 785 # calculate commit and file changes
786 786 changes = self._calculate_commit_id_changes(
787 787 old_commit_ids, new_commit_ids)
788 788 file_changes = self._calculate_file_changes(
789 789 old_diff_data, new_diff_data)
790 790
791 791 # set comments as outdated if DIFFS changed
792 792 CommentsModel().outdate_comments(
793 793 pull_request, old_diff_data=old_diff_data,
794 794 new_diff_data=new_diff_data)
795 795
796 796 commit_changes = (changes.added or changes.removed)
797 797 file_node_changes = (
798 798 file_changes.added or file_changes.modified or file_changes.removed)
799 799 pr_has_changes = commit_changes or file_node_changes
800 800
801 801 # Add an automatic comment to the pull request, in case
802 802 # anything has changed
803 803 if pr_has_changes:
804 804 update_comment = CommentsModel().create(
805 805 text=self._render_update_message(changes, file_changes),
806 806 repo=pull_request.target_repo,
807 807 user=pull_request.author,
808 808 pull_request=pull_request,
809 809 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
810 810
811 811 # Update status to "Under Review" for added commits
812 812 for commit_id in changes.added:
813 813 ChangesetStatusModel().set_status(
814 814 repo=pull_request.source_repo,
815 815 status=ChangesetStatus.STATUS_UNDER_REVIEW,
816 816 comment=update_comment,
817 817 user=pull_request.author,
818 818 pull_request=pull_request,
819 819 revision=commit_id)
820 820
821 821 log.debug(
822 822 'Updated pull request %s, added_ids: %s, common_ids: %s, '
823 823 'removed_ids: %s', pull_request.pull_request_id,
824 824 changes.added, changes.common, changes.removed)
825 825 log.debug(
826 826 'Updated pull request with the following file changes: %s',
827 827 file_changes)
828 828
829 829 log.info(
830 830 "Updated pull request %s from commit %s to commit %s, "
831 831 "stored new version %s of this pull request.",
832 832 pull_request.pull_request_id, source_ref_id,
833 833 pull_request.source_ref_parts.commit_id,
834 834 pull_request_version.pull_request_version_id)
835 835 Session().commit()
836 836 self._trigger_pull_request_hook(
837 837 pull_request, pull_request.author, 'update')
838 838
839 839 return UpdateResponse(
840 840 executed=True, reason=UpdateFailureReason.NONE,
841 841 old=pull_request, new=pull_request_version, changes=changes,
842 842 source_changed=source_changed, target_changed=target_changed)
843 843
844 844 def _create_version_from_snapshot(self, pull_request):
845 845 version = PullRequestVersion()
846 846 version.title = pull_request.title
847 847 version.description = pull_request.description
848 848 version.status = pull_request.status
849 849 version.created_on = datetime.datetime.now()
850 850 version.updated_on = pull_request.updated_on
851 851 version.user_id = pull_request.user_id
852 852 version.source_repo = pull_request.source_repo
853 853 version.source_ref = pull_request.source_ref
854 854 version.target_repo = pull_request.target_repo
855 855 version.target_ref = pull_request.target_ref
856 856
857 857 version._last_merge_source_rev = pull_request._last_merge_source_rev
858 858 version._last_merge_target_rev = pull_request._last_merge_target_rev
859 859 version.last_merge_status = pull_request.last_merge_status
860 860 version.shadow_merge_ref = pull_request.shadow_merge_ref
861 861 version.merge_rev = pull_request.merge_rev
862 862 version.reviewer_data = pull_request.reviewer_data
863 863
864 864 version.revisions = pull_request.revisions
865 865 version.pull_request = pull_request
866 866 Session().add(version)
867 867 Session().flush()
868 868
869 869 return version
870 870
871 871 def _generate_update_diffs(self, pull_request, pull_request_version):
872 872
873 873 diff_context = (
874 874 self.DIFF_CONTEXT +
875 875 CommentsModel.needed_extra_diff_context())
876 876
877 877 source_repo = pull_request_version.source_repo
878 878 source_ref_id = pull_request_version.source_ref_parts.commit_id
879 879 target_ref_id = pull_request_version.target_ref_parts.commit_id
880 880 old_diff = self._get_diff_from_pr_or_version(
881 881 source_repo, source_ref_id, target_ref_id, context=diff_context)
882 882
883 883 source_repo = pull_request.source_repo
884 884 source_ref_id = pull_request.source_ref_parts.commit_id
885 885 target_ref_id = pull_request.target_ref_parts.commit_id
886 886
887 887 new_diff = self._get_diff_from_pr_or_version(
888 888 source_repo, source_ref_id, target_ref_id, context=diff_context)
889 889
890 890 old_diff_data = diffs.DiffProcessor(old_diff)
891 891 old_diff_data.prepare()
892 892 new_diff_data = diffs.DiffProcessor(new_diff)
893 893 new_diff_data.prepare()
894 894
895 895 return old_diff_data, new_diff_data
896 896
897 897 def _link_comments_to_version(self, pull_request_version):
898 898 """
899 899 Link all unlinked comments of this pull request to the given version.
900 900
901 901 :param pull_request_version: The `PullRequestVersion` to which
902 902 the comments shall be linked.
903 903
904 904 """
905 905 pull_request = pull_request_version.pull_request
906 906 comments = ChangesetComment.query()\
907 907 .filter(
908 908 # TODO: johbo: Should we query for the repo at all here?
909 909 # Pending decision on how comments of PRs are to be related
910 910 # to either the source repo, the target repo or no repo at all.
911 911 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
912 912 ChangesetComment.pull_request == pull_request,
913 913 ChangesetComment.pull_request_version == None)\
914 914 .order_by(ChangesetComment.comment_id.asc())
915 915
916 916 # TODO: johbo: Find out why this breaks if it is done in a bulk
917 917 # operation.
918 918 for comment in comments:
919 919 comment.pull_request_version_id = (
920 920 pull_request_version.pull_request_version_id)
921 921 Session().add(comment)
922 922
923 923 def _calculate_commit_id_changes(self, old_ids, new_ids):
924 924 added = [x for x in new_ids if x not in old_ids]
925 925 common = [x for x in new_ids if x in old_ids]
926 926 removed = [x for x in old_ids if x not in new_ids]
927 927 total = new_ids
928 928 return ChangeTuple(added, common, removed, total)
929 929
930 930 def _calculate_file_changes(self, old_diff_data, new_diff_data):
931 931
932 932 old_files = OrderedDict()
933 933 for diff_data in old_diff_data.parsed_diff:
934 934 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
935 935
936 936 added_files = []
937 937 modified_files = []
938 938 removed_files = []
939 939 for diff_data in new_diff_data.parsed_diff:
940 940 new_filename = diff_data['filename']
941 941 new_hash = md5_safe(diff_data['raw_diff'])
942 942
943 943 old_hash = old_files.get(new_filename)
944 944 if not old_hash:
945 945 # file is not present in old diff, means it's added
946 946 added_files.append(new_filename)
947 947 else:
948 948 if new_hash != old_hash:
949 949 modified_files.append(new_filename)
950 950 # now remove a file from old, since we have seen it already
951 951 del old_files[new_filename]
952 952
953 953 # removed files is when there are present in old, but not in NEW,
954 954 # since we remove old files that are present in new diff, left-overs
955 955 # if any should be the removed files
956 956 removed_files.extend(old_files.keys())
957 957
958 958 return FileChangeTuple(added_files, modified_files, removed_files)
959 959
960 960 def _render_update_message(self, changes, file_changes):
961 961 """
962 962 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
963 963 so it's always looking the same disregarding on which default
964 964 renderer system is using.
965 965
966 966 :param changes: changes named tuple
967 967 :param file_changes: file changes named tuple
968 968
969 969 """
970 970 new_status = ChangesetStatus.get_status_lbl(
971 971 ChangesetStatus.STATUS_UNDER_REVIEW)
972 972
973 973 changed_files = (
974 974 file_changes.added + file_changes.modified + file_changes.removed)
975 975
976 976 params = {
977 977 'under_review_label': new_status,
978 978 'added_commits': changes.added,
979 979 'removed_commits': changes.removed,
980 980 'changed_files': changed_files,
981 981 'added_files': file_changes.added,
982 982 'modified_files': file_changes.modified,
983 983 'removed_files': file_changes.removed,
984 984 }
985 985 renderer = RstTemplateRenderer()
986 986 return renderer.render('pull_request_update.mako', **params)
987 987
988 988 def edit(self, pull_request, title, description, description_renderer, user):
989 989 pull_request = self.__get_pull_request(pull_request)
990 990 old_data = pull_request.get_api_data(with_merge_state=False)
991 991 if pull_request.is_closed():
992 992 raise ValueError('This pull request is closed')
993 993 if title:
994 994 pull_request.title = title
995 995 pull_request.description = description
996 996 pull_request.updated_on = datetime.datetime.now()
997 997 pull_request.description_renderer = description_renderer
998 998 Session().add(pull_request)
999 999 self._log_audit_action(
1000 1000 'repo.pull_request.edit', {'old_data': old_data},
1001 1001 user, pull_request)
1002 1002
1003 1003 def update_reviewers(self, pull_request, reviewer_data, user):
1004 1004 """
1005 1005 Update the reviewers in the pull request
1006 1006
1007 1007 :param pull_request: the pr to update
1008 1008 :param reviewer_data: list of tuples
1009 1009 [(user, ['reason1', 'reason2'], mandatory_flag, [rules])]
1010 1010 """
1011 1011 pull_request = self.__get_pull_request(pull_request)
1012 1012 if pull_request.is_closed():
1013 1013 raise ValueError('This pull request is closed')
1014 1014
1015 1015 reviewers = {}
1016 1016 for user_id, reasons, mandatory, rules in reviewer_data:
1017 1017 if isinstance(user_id, (int, basestring)):
1018 1018 user_id = self._get_user(user_id).user_id
1019 1019 reviewers[user_id] = {
1020 1020 'reasons': reasons, 'mandatory': mandatory}
1021 1021
1022 1022 reviewers_ids = set(reviewers.keys())
1023 1023 current_reviewers = PullRequestReviewers.query()\
1024 1024 .filter(PullRequestReviewers.pull_request ==
1025 1025 pull_request).all()
1026 1026 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
1027 1027
1028 1028 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
1029 1029 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
1030 1030
1031 1031 log.debug("Adding %s reviewers", ids_to_add)
1032 1032 log.debug("Removing %s reviewers", ids_to_remove)
1033 1033 changed = False
1034 1034 for uid in ids_to_add:
1035 1035 changed = True
1036 1036 _usr = self._get_user(uid)
1037 1037 reviewer = PullRequestReviewers()
1038 1038 reviewer.user = _usr
1039 1039 reviewer.pull_request = pull_request
1040 1040 reviewer.reasons = reviewers[uid]['reasons']
1041 1041 # NOTE(marcink): mandatory shouldn't be changed now
1042 1042 # reviewer.mandatory = reviewers[uid]['reasons']
1043 1043 Session().add(reviewer)
1044 1044 self._log_audit_action(
1045 1045 'repo.pull_request.reviewer.add', {'data': reviewer.get_dict()},
1046 1046 user, pull_request)
1047 1047
1048 1048 for uid in ids_to_remove:
1049 1049 changed = True
1050 1050 reviewers = PullRequestReviewers.query()\
1051 1051 .filter(PullRequestReviewers.user_id == uid,
1052 1052 PullRequestReviewers.pull_request == pull_request)\
1053 1053 .all()
1054 1054 # use .all() in case we accidentally added the same person twice
1055 1055 # this CAN happen due to the lack of DB checks
1056 1056 for obj in reviewers:
1057 1057 old_data = obj.get_dict()
1058 1058 Session().delete(obj)
1059 1059 self._log_audit_action(
1060 1060 'repo.pull_request.reviewer.delete',
1061 1061 {'old_data': old_data}, user, pull_request)
1062 1062
1063 1063 if changed:
1064 1064 pull_request.updated_on = datetime.datetime.now()
1065 1065 Session().add(pull_request)
1066 1066
1067 1067 self.notify_reviewers(pull_request, ids_to_add)
1068 1068 return ids_to_add, ids_to_remove
1069 1069
1070 1070 def get_url(self, pull_request, request=None, permalink=False):
1071 1071 if not request:
1072 1072 request = get_current_request()
1073 1073
1074 1074 if permalink:
1075 1075 return request.route_url(
1076 1076 'pull_requests_global',
1077 1077 pull_request_id=pull_request.pull_request_id,)
1078 1078 else:
1079 1079 return request.route_url('pullrequest_show',
1080 1080 repo_name=safe_str(pull_request.target_repo.repo_name),
1081 1081 pull_request_id=pull_request.pull_request_id,)
1082 1082
1083 1083 def get_shadow_clone_url(self, pull_request, request=None):
1084 1084 """
1085 1085 Returns qualified url pointing to the shadow repository. If this pull
1086 1086 request is closed there is no shadow repository and ``None`` will be
1087 1087 returned.
1088 1088 """
1089 1089 if pull_request.is_closed():
1090 1090 return None
1091 1091 else:
1092 1092 pr_url = urllib.unquote(self.get_url(pull_request, request=request))
1093 1093 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1094 1094
1095 1095 def notify_reviewers(self, pull_request, reviewers_ids):
1096 1096 # notification to reviewers
1097 1097 if not reviewers_ids:
1098 1098 return
1099 1099
1100 1100 pull_request_obj = pull_request
1101 1101 # get the current participants of this pull request
1102 1102 recipients = reviewers_ids
1103 1103 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1104 1104
1105 1105 pr_source_repo = pull_request_obj.source_repo
1106 1106 pr_target_repo = pull_request_obj.target_repo
1107 1107
1108 1108 pr_url = h.route_url('pullrequest_show',
1109 1109 repo_name=pr_target_repo.repo_name,
1110 1110 pull_request_id=pull_request_obj.pull_request_id,)
1111 1111
1112 1112 # set some variables for email notification
1113 1113 pr_target_repo_url = h.route_url(
1114 1114 'repo_summary', repo_name=pr_target_repo.repo_name)
1115 1115
1116 1116 pr_source_repo_url = h.route_url(
1117 1117 'repo_summary', repo_name=pr_source_repo.repo_name)
1118 1118
1119 1119 # pull request specifics
1120 1120 pull_request_commits = [
1121 1121 (x.raw_id, x.message)
1122 1122 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1123 1123
1124 1124 kwargs = {
1125 1125 'user': pull_request.author,
1126 1126 'pull_request': pull_request_obj,
1127 1127 'pull_request_commits': pull_request_commits,
1128 1128
1129 1129 'pull_request_target_repo': pr_target_repo,
1130 1130 'pull_request_target_repo_url': pr_target_repo_url,
1131 1131
1132 1132 'pull_request_source_repo': pr_source_repo,
1133 1133 'pull_request_source_repo_url': pr_source_repo_url,
1134 1134
1135 1135 'pull_request_url': pr_url,
1136 1136 }
1137 1137
1138 1138 # pre-generate the subject for notification itself
1139 1139 (subject,
1140 1140 _h, _e, # we don't care about those
1141 1141 body_plaintext) = EmailNotificationModel().render_email(
1142 1142 notification_type, **kwargs)
1143 1143
1144 1144 # create notification objects, and emails
1145 1145 NotificationModel().create(
1146 1146 created_by=pull_request.author,
1147 1147 notification_subject=subject,
1148 1148 notification_body=body_plaintext,
1149 1149 notification_type=notification_type,
1150 1150 recipients=recipients,
1151 1151 email_kwargs=kwargs,
1152 1152 )
1153 1153
1154 1154 def delete(self, pull_request, user):
1155 1155 pull_request = self.__get_pull_request(pull_request)
1156 1156 old_data = pull_request.get_api_data(with_merge_state=False)
1157 1157 self._cleanup_merge_workspace(pull_request)
1158 1158 self._log_audit_action(
1159 1159 'repo.pull_request.delete', {'old_data': old_data},
1160 1160 user, pull_request)
1161 1161 Session().delete(pull_request)
1162 1162
1163 1163 def close_pull_request(self, pull_request, user):
1164 1164 pull_request = self.__get_pull_request(pull_request)
1165 1165 self._cleanup_merge_workspace(pull_request)
1166 1166 pull_request.status = PullRequest.STATUS_CLOSED
1167 1167 pull_request.updated_on = datetime.datetime.now()
1168 1168 Session().add(pull_request)
1169 1169 self._trigger_pull_request_hook(
1170 1170 pull_request, pull_request.author, 'close')
1171 1171
1172 1172 pr_data = pull_request.get_api_data(with_merge_state=False)
1173 1173 self._log_audit_action(
1174 1174 'repo.pull_request.close', {'data': pr_data}, user, pull_request)
1175 1175
1176 1176 def close_pull_request_with_comment(
1177 self, pull_request, user, repo, message=None):
1177 self, pull_request, user, repo, message=None, auth_user=None):
1178 1178
1179 1179 pull_request_review_status = pull_request.calculated_review_status()
1180 1180
1181 1181 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1182 1182 # approved only if we have voting consent
1183 1183 status = ChangesetStatus.STATUS_APPROVED
1184 1184 else:
1185 1185 status = ChangesetStatus.STATUS_REJECTED
1186 1186 status_lbl = ChangesetStatus.get_status_lbl(status)
1187 1187
1188 1188 default_message = (
1189 1189 'Closing with status change {transition_icon} {status}.'
1190 1190 ).format(transition_icon='>', status=status_lbl)
1191 1191 text = message or default_message
1192 1192
1193 1193 # create a comment, and link it to new status
1194 1194 comment = CommentsModel().create(
1195 1195 text=text,
1196 1196 repo=repo.repo_id,
1197 1197 user=user.user_id,
1198 1198 pull_request=pull_request.pull_request_id,
1199 1199 status_change=status_lbl,
1200 1200 status_change_type=status,
1201 closing_pr=True
1201 closing_pr=True,
1202 auth_user=auth_user,
1202 1203 )
1203 1204
1204 1205 # calculate old status before we change it
1205 1206 old_calculated_status = pull_request.calculated_review_status()
1206 1207 ChangesetStatusModel().set_status(
1207 1208 repo.repo_id,
1208 1209 status,
1209 1210 user.user_id,
1210 1211 comment=comment,
1211 1212 pull_request=pull_request.pull_request_id
1212 1213 )
1213 1214
1214 1215 Session().flush()
1215 1216 events.trigger(events.PullRequestCommentEvent(pull_request, comment))
1216 1217 # we now calculate the status of pull request again, and based on that
1217 1218 # calculation trigger status change. This might happen in cases
1218 1219 # that non-reviewer admin closes a pr, which means his vote doesn't
1219 1220 # change the status, while if he's a reviewer this might change it.
1220 1221 calculated_status = pull_request.calculated_review_status()
1221 1222 if old_calculated_status != calculated_status:
1222 1223 self._trigger_pull_request_hook(
1223 1224 pull_request, user, 'review_status_change')
1224 1225
1225 1226 # finally close the PR
1226 1227 PullRequestModel().close_pull_request(
1227 1228 pull_request.pull_request_id, user)
1228 1229
1229 1230 return comment, status
1230 1231
1231 1232 def merge_status(self, pull_request, translator=None,
1232 1233 force_shadow_repo_refresh=False):
1233 1234 _ = translator or get_current_request().translate
1234 1235
1235 1236 if not self._is_merge_enabled(pull_request):
1236 1237 return False, _('Server-side pull request merging is disabled.')
1237 1238 if pull_request.is_closed():
1238 1239 return False, _('This pull request is closed.')
1239 1240 merge_possible, msg = self._check_repo_requirements(
1240 1241 target=pull_request.target_repo, source=pull_request.source_repo,
1241 1242 translator=_)
1242 1243 if not merge_possible:
1243 1244 return merge_possible, msg
1244 1245
1245 1246 try:
1246 1247 resp = self._try_merge(
1247 1248 pull_request,
1248 1249 force_shadow_repo_refresh=force_shadow_repo_refresh)
1249 1250 log.debug("Merge response: %s", resp)
1250 1251 status = resp.possible, self.merge_status_message(
1251 1252 resp.failure_reason)
1252 1253 except NotImplementedError:
1253 1254 status = False, _('Pull request merging is not supported.')
1254 1255
1255 1256 return status
1256 1257
1257 1258 def _check_repo_requirements(self, target, source, translator):
1258 1259 """
1259 1260 Check if `target` and `source` have compatible requirements.
1260 1261
1261 1262 Currently this is just checking for largefiles.
1262 1263 """
1263 1264 _ = translator
1264 1265 target_has_largefiles = self._has_largefiles(target)
1265 1266 source_has_largefiles = self._has_largefiles(source)
1266 1267 merge_possible = True
1267 1268 message = u''
1268 1269
1269 1270 if target_has_largefiles != source_has_largefiles:
1270 1271 merge_possible = False
1271 1272 if source_has_largefiles:
1272 1273 message = _(
1273 1274 'Target repository large files support is disabled.')
1274 1275 else:
1275 1276 message = _(
1276 1277 'Source repository large files support is disabled.')
1277 1278
1278 1279 return merge_possible, message
1279 1280
1280 1281 def _has_largefiles(self, repo):
1281 1282 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1282 1283 'extensions', 'largefiles')
1283 1284 return largefiles_ui and largefiles_ui[0].active
1284 1285
1285 1286 def _try_merge(self, pull_request, force_shadow_repo_refresh=False):
1286 1287 """
1287 1288 Try to merge the pull request and return the merge status.
1288 1289 """
1289 1290 log.debug(
1290 1291 "Trying out if the pull request %s can be merged. Force_refresh=%s",
1291 1292 pull_request.pull_request_id, force_shadow_repo_refresh)
1292 1293 target_vcs = pull_request.target_repo.scm_instance()
1293 1294
1294 1295 # Refresh the target reference.
1295 1296 try:
1296 1297 target_ref = self._refresh_reference(
1297 1298 pull_request.target_ref_parts, target_vcs)
1298 1299 except CommitDoesNotExistError:
1299 1300 merge_state = MergeResponse(
1300 1301 False, False, None, MergeFailureReason.MISSING_TARGET_REF)
1301 1302 return merge_state
1302 1303
1303 1304 target_locked = pull_request.target_repo.locked
1304 1305 if target_locked and target_locked[0]:
1305 1306 log.debug("The target repository is locked.")
1306 1307 merge_state = MergeResponse(
1307 1308 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
1308 1309 elif force_shadow_repo_refresh or self._needs_merge_state_refresh(
1309 1310 pull_request, target_ref):
1310 1311 log.debug("Refreshing the merge status of the repository.")
1311 1312 merge_state = self._refresh_merge_state(
1312 1313 pull_request, target_vcs, target_ref)
1313 1314 else:
1314 1315 possible = pull_request.\
1315 1316 last_merge_status == MergeFailureReason.NONE
1316 1317 merge_state = MergeResponse(
1317 1318 possible, False, None, pull_request.last_merge_status)
1318 1319
1319 1320 return merge_state
1320 1321
1321 1322 def _refresh_reference(self, reference, vcs_repository):
1322 1323 if reference.type in ('branch', 'book'):
1323 1324 name_or_id = reference.name
1324 1325 else:
1325 1326 name_or_id = reference.commit_id
1326 1327 refreshed_commit = vcs_repository.get_commit(name_or_id)
1327 1328 refreshed_reference = Reference(
1328 1329 reference.type, reference.name, refreshed_commit.raw_id)
1329 1330 return refreshed_reference
1330 1331
1331 1332 def _needs_merge_state_refresh(self, pull_request, target_reference):
1332 1333 return not(
1333 1334 pull_request.revisions and
1334 1335 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1335 1336 target_reference.commit_id == pull_request._last_merge_target_rev)
1336 1337
1337 1338 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1338 1339 workspace_id = self._workspace_id(pull_request)
1339 1340 source_vcs = pull_request.source_repo.scm_instance()
1340 1341 repo_id = pull_request.target_repo.repo_id
1341 1342 use_rebase = self._use_rebase_for_merging(pull_request)
1342 1343 close_branch = self._close_branch_before_merging(pull_request)
1343 1344 merge_state = target_vcs.merge(
1344 1345 repo_id, workspace_id,
1345 1346 target_reference, source_vcs, pull_request.source_ref_parts,
1346 1347 dry_run=True, use_rebase=use_rebase,
1347 1348 close_branch=close_branch)
1348 1349
1349 1350 # Do not store the response if there was an unknown error.
1350 1351 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1351 1352 pull_request._last_merge_source_rev = \
1352 1353 pull_request.source_ref_parts.commit_id
1353 1354 pull_request._last_merge_target_rev = target_reference.commit_id
1354 1355 pull_request.last_merge_status = merge_state.failure_reason
1355 1356 pull_request.shadow_merge_ref = merge_state.merge_ref
1356 1357 Session().add(pull_request)
1357 1358 Session().commit()
1358 1359
1359 1360 return merge_state
1360 1361
1361 1362 def _workspace_id(self, pull_request):
1362 1363 workspace_id = 'pr-%s' % pull_request.pull_request_id
1363 1364 return workspace_id
1364 1365
1365 1366 def merge_status_message(self, status_code):
1366 1367 """
1367 1368 Return a human friendly error message for the given merge status code.
1368 1369 """
1369 1370 return self.MERGE_STATUS_MESSAGES[status_code]
1370 1371
1371 1372 def generate_repo_data(self, repo, commit_id=None, branch=None,
1372 1373 bookmark=None, translator=None):
1373 1374 from rhodecode.model.repo import RepoModel
1374 1375
1375 1376 all_refs, selected_ref = \
1376 1377 self._get_repo_pullrequest_sources(
1377 1378 repo.scm_instance(), commit_id=commit_id,
1378 1379 branch=branch, bookmark=bookmark, translator=translator)
1379 1380
1380 1381 refs_select2 = []
1381 1382 for element in all_refs:
1382 1383 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1383 1384 refs_select2.append({'text': element[1], 'children': children})
1384 1385
1385 1386 return {
1386 1387 'user': {
1387 1388 'user_id': repo.user.user_id,
1388 1389 'username': repo.user.username,
1389 1390 'firstname': repo.user.first_name,
1390 1391 'lastname': repo.user.last_name,
1391 1392 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1392 1393 },
1393 1394 'name': repo.repo_name,
1394 1395 'link': RepoModel().get_url(repo),
1395 1396 'description': h.chop_at_smart(repo.description_safe, '\n'),
1396 1397 'refs': {
1397 1398 'all_refs': all_refs,
1398 1399 'selected_ref': selected_ref,
1399 1400 'select2_refs': refs_select2
1400 1401 }
1401 1402 }
1402 1403
1403 1404 def generate_pullrequest_title(self, source, source_ref, target):
1404 1405 return u'{source}#{at_ref} to {target}'.format(
1405 1406 source=source,
1406 1407 at_ref=source_ref,
1407 1408 target=target,
1408 1409 )
1409 1410
1410 1411 def _cleanup_merge_workspace(self, pull_request):
1411 1412 # Merging related cleanup
1412 1413 repo_id = pull_request.target_repo.repo_id
1413 1414 target_scm = pull_request.target_repo.scm_instance()
1414 1415 workspace_id = self._workspace_id(pull_request)
1415 1416
1416 1417 try:
1417 1418 target_scm.cleanup_merge_workspace(repo_id, workspace_id)
1418 1419 except NotImplementedError:
1419 1420 pass
1420 1421
1421 1422 def _get_repo_pullrequest_sources(
1422 1423 self, repo, commit_id=None, branch=None, bookmark=None,
1423 1424 translator=None):
1424 1425 """
1425 1426 Return a structure with repo's interesting commits, suitable for
1426 1427 the selectors in pullrequest controller
1427 1428
1428 1429 :param commit_id: a commit that must be in the list somehow
1429 1430 and selected by default
1430 1431 :param branch: a branch that must be in the list and selected
1431 1432 by default - even if closed
1432 1433 :param bookmark: a bookmark that must be in the list and selected
1433 1434 """
1434 1435 _ = translator or get_current_request().translate
1435 1436
1436 1437 commit_id = safe_str(commit_id) if commit_id else None
1437 1438 branch = safe_str(branch) if branch else None
1438 1439 bookmark = safe_str(bookmark) if bookmark else None
1439 1440
1440 1441 selected = None
1441 1442
1442 1443 # order matters: first source that has commit_id in it will be selected
1443 1444 sources = []
1444 1445 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1445 1446 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1446 1447
1447 1448 if commit_id:
1448 1449 ref_commit = (h.short_id(commit_id), commit_id)
1449 1450 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1450 1451
1451 1452 sources.append(
1452 1453 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1453 1454 )
1454 1455
1455 1456 groups = []
1456 1457 for group_key, ref_list, group_name, match in sources:
1457 1458 group_refs = []
1458 1459 for ref_name, ref_id in ref_list:
1459 1460 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1460 1461 group_refs.append((ref_key, ref_name))
1461 1462
1462 1463 if not selected:
1463 1464 if set([commit_id, match]) & set([ref_id, ref_name]):
1464 1465 selected = ref_key
1465 1466
1466 1467 if group_refs:
1467 1468 groups.append((group_refs, group_name))
1468 1469
1469 1470 if not selected:
1470 1471 ref = commit_id or branch or bookmark
1471 1472 if ref:
1472 1473 raise CommitDoesNotExistError(
1473 1474 'No commit refs could be found matching: %s' % ref)
1474 1475 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1475 1476 selected = 'branch:%s:%s' % (
1476 1477 repo.DEFAULT_BRANCH_NAME,
1477 1478 repo.branches[repo.DEFAULT_BRANCH_NAME]
1478 1479 )
1479 1480 elif repo.commit_ids:
1480 1481 # make the user select in this case
1481 1482 selected = None
1482 1483 else:
1483 1484 raise EmptyRepositoryError()
1484 1485 return groups, selected
1485 1486
1486 1487 def get_diff(self, source_repo, source_ref_id, target_ref_id, context=DIFF_CONTEXT):
1487 1488 return self._get_diff_from_pr_or_version(
1488 1489 source_repo, source_ref_id, target_ref_id, context=context)
1489 1490
1490 1491 def _get_diff_from_pr_or_version(
1491 1492 self, source_repo, source_ref_id, target_ref_id, context):
1492 1493 target_commit = source_repo.get_commit(
1493 1494 commit_id=safe_str(target_ref_id))
1494 1495 source_commit = source_repo.get_commit(
1495 1496 commit_id=safe_str(source_ref_id))
1496 1497 if isinstance(source_repo, Repository):
1497 1498 vcs_repo = source_repo.scm_instance()
1498 1499 else:
1499 1500 vcs_repo = source_repo
1500 1501
1501 1502 # TODO: johbo: In the context of an update, we cannot reach
1502 1503 # the old commit anymore with our normal mechanisms. It needs
1503 1504 # some sort of special support in the vcs layer to avoid this
1504 1505 # workaround.
1505 1506 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1506 1507 vcs_repo.alias == 'git'):
1507 1508 source_commit.raw_id = safe_str(source_ref_id)
1508 1509
1509 1510 log.debug('calculating diff between '
1510 1511 'source_ref:%s and target_ref:%s for repo `%s`',
1511 1512 target_ref_id, source_ref_id,
1512 1513 safe_unicode(vcs_repo.path))
1513 1514
1514 1515 vcs_diff = vcs_repo.get_diff(
1515 1516 commit1=target_commit, commit2=source_commit, context=context)
1516 1517 return vcs_diff
1517 1518
1518 1519 def _is_merge_enabled(self, pull_request):
1519 1520 return self._get_general_setting(
1520 1521 pull_request, 'rhodecode_pr_merge_enabled')
1521 1522
1522 1523 def _use_rebase_for_merging(self, pull_request):
1523 1524 repo_type = pull_request.target_repo.repo_type
1524 1525 if repo_type == 'hg':
1525 1526 return self._get_general_setting(
1526 1527 pull_request, 'rhodecode_hg_use_rebase_for_merging')
1527 1528 elif repo_type == 'git':
1528 1529 return self._get_general_setting(
1529 1530 pull_request, 'rhodecode_git_use_rebase_for_merging')
1530 1531
1531 1532 return False
1532 1533
1533 1534 def _close_branch_before_merging(self, pull_request):
1534 1535 repo_type = pull_request.target_repo.repo_type
1535 1536 if repo_type == 'hg':
1536 1537 return self._get_general_setting(
1537 1538 pull_request, 'rhodecode_hg_close_branch_before_merging')
1538 1539 elif repo_type == 'git':
1539 1540 return self._get_general_setting(
1540 1541 pull_request, 'rhodecode_git_close_branch_before_merging')
1541 1542
1542 1543 return False
1543 1544
1544 1545 def _get_general_setting(self, pull_request, settings_key, default=False):
1545 1546 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1546 1547 settings = settings_model.get_general_settings()
1547 1548 return settings.get(settings_key, default)
1548 1549
1549 1550 def _log_audit_action(self, action, action_data, user, pull_request):
1550 1551 audit_logger.store(
1551 1552 action=action,
1552 1553 action_data=action_data,
1553 1554 user=user,
1554 1555 repo=pull_request.target_repo)
1555 1556
1556 1557 def get_reviewer_functions(self):
1557 1558 """
1558 1559 Fetches functions for validation and fetching default reviewers.
1559 1560 If available we use the EE package, else we fallback to CE
1560 1561 package functions
1561 1562 """
1562 1563 try:
1563 1564 from rc_reviewers.utils import get_default_reviewers_data
1564 1565 from rc_reviewers.utils import validate_default_reviewers
1565 1566 except ImportError:
1566 1567 from rhodecode.apps.repository.utils import \
1567 1568 get_default_reviewers_data
1568 1569 from rhodecode.apps.repository.utils import \
1569 1570 validate_default_reviewers
1570 1571
1571 1572 return get_default_reviewers_data, validate_default_reviewers
1572 1573
1573 1574
1574 1575 class MergeCheck(object):
1575 1576 """
1576 1577 Perform Merge Checks and returns a check object which stores information
1577 1578 about merge errors, and merge conditions
1578 1579 """
1579 1580 TODO_CHECK = 'todo'
1580 1581 PERM_CHECK = 'perm'
1581 1582 REVIEW_CHECK = 'review'
1582 1583 MERGE_CHECK = 'merge'
1583 1584
1584 1585 def __init__(self):
1585 1586 self.review_status = None
1586 1587 self.merge_possible = None
1587 1588 self.merge_msg = ''
1588 1589 self.failed = None
1589 1590 self.errors = []
1590 1591 self.error_details = OrderedDict()
1591 1592
1592 1593 def push_error(self, error_type, message, error_key, details):
1593 1594 self.failed = True
1594 1595 self.errors.append([error_type, message])
1595 1596 self.error_details[error_key] = dict(
1596 1597 details=details,
1597 1598 error_type=error_type,
1598 1599 message=message
1599 1600 )
1600 1601
1601 1602 @classmethod
1602 1603 def validate(cls, pull_request, auth_user, translator, fail_early=False,
1603 1604 force_shadow_repo_refresh=False):
1604 1605 _ = translator
1605 1606 merge_check = cls()
1606 1607
1607 1608 # permissions to merge
1608 1609 user_allowed_to_merge = PullRequestModel().check_user_merge(
1609 1610 pull_request, auth_user)
1610 1611 if not user_allowed_to_merge:
1611 1612 log.debug("MergeCheck: cannot merge, approval is pending.")
1612 1613
1613 1614 msg = _('User `{}` not allowed to perform merge.').format(auth_user.username)
1614 1615 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1615 1616 if fail_early:
1616 1617 return merge_check
1617 1618
1618 1619 # permission to merge into the target branch
1619 1620 target_commit_id = pull_request.target_ref_parts.commit_id
1620 1621 if pull_request.target_ref_parts.type == 'branch':
1621 1622 branch_name = pull_request.target_ref_parts.name
1622 1623 else:
1623 1624 # for mercurial we can always figure out the branch from the commit
1624 1625 # in case of bookmark
1625 1626 target_commit = pull_request.target_repo.get_commit(target_commit_id)
1626 1627 branch_name = target_commit.branch
1627 1628
1628 1629 rule, branch_perm = auth_user.get_rule_and_branch_permission(
1629 1630 pull_request.target_repo.repo_name, branch_name)
1630 1631 if branch_perm and branch_perm == 'branch.none':
1631 1632 msg = _('Target branch `{}` changes rejected by rule {}.').format(
1632 1633 branch_name, rule)
1633 1634 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
1634 1635 if fail_early:
1635 1636 return merge_check
1636 1637
1637 1638 # review status, must be always present
1638 1639 review_status = pull_request.calculated_review_status()
1639 1640 merge_check.review_status = review_status
1640 1641
1641 1642 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
1642 1643 if not status_approved:
1643 1644 log.debug("MergeCheck: cannot merge, approval is pending.")
1644 1645
1645 1646 msg = _('Pull request reviewer approval is pending.')
1646 1647
1647 1648 merge_check.push_error(
1648 1649 'warning', msg, cls.REVIEW_CHECK, review_status)
1649 1650
1650 1651 if fail_early:
1651 1652 return merge_check
1652 1653
1653 1654 # left over TODOs
1654 1655 todos = CommentsModel().get_unresolved_todos(pull_request)
1655 1656 if todos:
1656 1657 log.debug("MergeCheck: cannot merge, {} "
1657 1658 "unresolved todos left.".format(len(todos)))
1658 1659
1659 1660 if len(todos) == 1:
1660 1661 msg = _('Cannot merge, {} TODO still not resolved.').format(
1661 1662 len(todos))
1662 1663 else:
1663 1664 msg = _('Cannot merge, {} TODOs still not resolved.').format(
1664 1665 len(todos))
1665 1666
1666 1667 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
1667 1668
1668 1669 if fail_early:
1669 1670 return merge_check
1670 1671
1671 1672 # merge possible, here is the filesystem simulation + shadow repo
1672 1673 merge_status, msg = PullRequestModel().merge_status(
1673 1674 pull_request, translator=translator,
1674 1675 force_shadow_repo_refresh=force_shadow_repo_refresh)
1675 1676 merge_check.merge_possible = merge_status
1676 1677 merge_check.merge_msg = msg
1677 1678 if not merge_status:
1678 1679 log.debug(
1679 1680 "MergeCheck: cannot merge, pull request merge not possible.")
1680 1681 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
1681 1682
1682 1683 if fail_early:
1683 1684 return merge_check
1684 1685
1685 1686 log.debug('MergeCheck: is failed: %s', merge_check.failed)
1686 1687 return merge_check
1687 1688
1688 1689 @classmethod
1689 1690 def get_merge_conditions(cls, pull_request, translator):
1690 1691 _ = translator
1691 1692 merge_details = {}
1692 1693
1693 1694 model = PullRequestModel()
1694 1695 use_rebase = model._use_rebase_for_merging(pull_request)
1695 1696
1696 1697 if use_rebase:
1697 1698 merge_details['merge_strategy'] = dict(
1698 1699 details={},
1699 1700 message=_('Merge strategy: rebase')
1700 1701 )
1701 1702 else:
1702 1703 merge_details['merge_strategy'] = dict(
1703 1704 details={},
1704 1705 message=_('Merge strategy: explicit merge commit')
1705 1706 )
1706 1707
1707 1708 close_branch = model._close_branch_before_merging(pull_request)
1708 1709 if close_branch:
1709 1710 repo_type = pull_request.target_repo.repo_type
1710 1711 if repo_type == 'hg':
1711 1712 close_msg = _('Source branch will be closed after merge.')
1712 1713 elif repo_type == 'git':
1713 1714 close_msg = _('Source branch will be deleted after merge.')
1714 1715
1715 1716 merge_details['close_branch'] = dict(
1716 1717 details={},
1717 1718 message=close_msg
1718 1719 )
1719 1720
1720 1721 return merge_details
1721 1722
1722 1723 ChangeTuple = collections.namedtuple(
1723 1724 'ChangeTuple', ['added', 'common', 'removed', 'total'])
1724 1725
1725 1726 FileChangeTuple = collections.namedtuple(
1726 1727 'FileChangeTuple', ['added', 'modified', 'removed'])
General Comments 0
You need to be logged in to leave comments. Login now