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