##// END OF EJS Templates
reviewers: optimize diff data, and creation of PR with advanced default reviewers
marcink -
r4510:b532b1b7 stable
parent child Browse files
Show More
@@ -1,1052 +1,1056 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 import logging
23 23
24 24 from rhodecode.api import jsonrpc_method, JSONRPCError, JSONRPCValidationError
25 25 from rhodecode.api.utils import (
26 26 has_superadmin_permission, Optional, OAttr, get_repo_or_error,
27 27 get_pull_request_or_error, get_commit_or_error, get_user_or_error,
28 28 validate_repo_permissions, resolve_ref_or_error, validate_set_owner_permissions)
29 29 from rhodecode.lib import channelstream
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, PullRequest
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 ReviewerListSchema
40 40
41 41 log = logging.getLogger(__name__)
42 42
43 43
44 44 @jsonrpc_method()
45 45 def get_pull_request(request, apiuser, pullrequestid, repoid=Optional(None),
46 46 merge_state=Optional(False)):
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 :param merge_state: Optional calculate merge state for each repository.
58 58 This could result in longer time to fetch the data
59 59 :type merge_state: bool
60 60
61 61 Example output:
62 62
63 63 .. code-block:: bash
64 64
65 65 "id": <id_given_in_input>,
66 66 "result":
67 67 {
68 68 "pull_request_id": "<pull_request_id>",
69 69 "url": "<url>",
70 70 "title": "<title>",
71 71 "description": "<description>",
72 72 "status" : "<status>",
73 73 "created_on": "<date_time_created>",
74 74 "updated_on": "<date_time_updated>",
75 75 "versions": "<number_or_versions_of_pr>",
76 76 "commit_ids": [
77 77 ...
78 78 "<commit_id>",
79 79 "<commit_id>",
80 80 ...
81 81 ],
82 82 "review_status": "<review_status>",
83 83 "mergeable": {
84 84 "status": "<bool>",
85 85 "message": "<message>",
86 86 },
87 87 "source": {
88 88 "clone_url": "<clone_url>",
89 89 "repository": "<repository_name>",
90 90 "reference":
91 91 {
92 92 "name": "<name>",
93 93 "type": "<type>",
94 94 "commit_id": "<commit_id>",
95 95 }
96 96 },
97 97 "target": {
98 98 "clone_url": "<clone_url>",
99 99 "repository": "<repository_name>",
100 100 "reference":
101 101 {
102 102 "name": "<name>",
103 103 "type": "<type>",
104 104 "commit_id": "<commit_id>",
105 105 }
106 106 },
107 107 "merge": {
108 108 "clone_url": "<clone_url>",
109 109 "reference":
110 110 {
111 111 "name": "<name>",
112 112 "type": "<type>",
113 113 "commit_id": "<commit_id>",
114 114 }
115 115 },
116 116 "author": <user_obj>,
117 117 "reviewers": [
118 118 ...
119 119 {
120 120 "user": "<user_obj>",
121 121 "review_status": "<review_status>",
122 122 }
123 123 ...
124 124 ]
125 125 },
126 126 "error": null
127 127 """
128 128
129 129 pull_request = get_pull_request_or_error(pullrequestid)
130 130 if Optional.extract(repoid):
131 131 repo = get_repo_or_error(repoid)
132 132 else:
133 133 repo = pull_request.target_repo
134 134
135 135 if not PullRequestModel().check_user_read(pull_request, apiuser, api=True):
136 136 raise JSONRPCError('repository `%s` or pull request `%s` '
137 137 'does not exist' % (repoid, pullrequestid))
138 138
139 139 # NOTE(marcink): only calculate and return merge state if the pr state is 'created'
140 140 # otherwise we can lock the repo on calculation of merge state while update/merge
141 141 # is happening.
142 142 pr_created = pull_request.pull_request_state == pull_request.STATE_CREATED
143 143 merge_state = Optional.extract(merge_state, binary=True) and pr_created
144 144 data = pull_request.get_api_data(with_merge_state=merge_state)
145 145 return data
146 146
147 147
148 148 @jsonrpc_method()
149 149 def get_pull_requests(request, apiuser, repoid, status=Optional('new'),
150 150 merge_state=Optional(False)):
151 151 """
152 152 Get all pull requests from the repository specified in `repoid`.
153 153
154 154 :param apiuser: This is filled automatically from the |authtoken|.
155 155 :type apiuser: AuthUser
156 156 :param repoid: Optional repository name or repository ID.
157 157 :type repoid: str or int
158 158 :param status: Only return pull requests with the specified status.
159 159 Valid options are.
160 160 * ``new`` (default)
161 161 * ``open``
162 162 * ``closed``
163 163 :type status: str
164 164 :param merge_state: Optional calculate merge state for each repository.
165 165 This could result in longer time to fetch the data
166 166 :type merge_state: bool
167 167
168 168 Example output:
169 169
170 170 .. code-block:: bash
171 171
172 172 "id": <id_given_in_input>,
173 173 "result":
174 174 [
175 175 ...
176 176 {
177 177 "pull_request_id": "<pull_request_id>",
178 178 "url": "<url>",
179 179 "title" : "<title>",
180 180 "description": "<description>",
181 181 "status": "<status>",
182 182 "created_on": "<date_time_created>",
183 183 "updated_on": "<date_time_updated>",
184 184 "commit_ids": [
185 185 ...
186 186 "<commit_id>",
187 187 "<commit_id>",
188 188 ...
189 189 ],
190 190 "review_status": "<review_status>",
191 191 "mergeable": {
192 192 "status": "<bool>",
193 193 "message: "<message>",
194 194 },
195 195 "source": {
196 196 "clone_url": "<clone_url>",
197 197 "reference":
198 198 {
199 199 "name": "<name>",
200 200 "type": "<type>",
201 201 "commit_id": "<commit_id>",
202 202 }
203 203 },
204 204 "target": {
205 205 "clone_url": "<clone_url>",
206 206 "reference":
207 207 {
208 208 "name": "<name>",
209 209 "type": "<type>",
210 210 "commit_id": "<commit_id>",
211 211 }
212 212 },
213 213 "merge": {
214 214 "clone_url": "<clone_url>",
215 215 "reference":
216 216 {
217 217 "name": "<name>",
218 218 "type": "<type>",
219 219 "commit_id": "<commit_id>",
220 220 }
221 221 },
222 222 "author": <user_obj>,
223 223 "reviewers": [
224 224 ...
225 225 {
226 226 "user": "<user_obj>",
227 227 "review_status": "<review_status>",
228 228 }
229 229 ...
230 230 ]
231 231 }
232 232 ...
233 233 ],
234 234 "error": null
235 235
236 236 """
237 237 repo = get_repo_or_error(repoid)
238 238 if not has_superadmin_permission(apiuser):
239 239 _perms = (
240 240 'repository.admin', 'repository.write', 'repository.read',)
241 241 validate_repo_permissions(apiuser, repoid, repo, _perms)
242 242
243 243 status = Optional.extract(status)
244 244 merge_state = Optional.extract(merge_state, binary=True)
245 245 pull_requests = PullRequestModel().get_all(repo, statuses=[status],
246 246 order_by='id', order_dir='desc')
247 247 data = [pr.get_api_data(with_merge_state=merge_state) for pr in pull_requests]
248 248 return data
249 249
250 250
251 251 @jsonrpc_method()
252 252 def merge_pull_request(
253 253 request, apiuser, pullrequestid, repoid=Optional(None),
254 254 userid=Optional(OAttr('apiuser'))):
255 255 """
256 256 Merge the pull request specified by `pullrequestid` into its target
257 257 repository.
258 258
259 259 :param apiuser: This is filled automatically from the |authtoken|.
260 260 :type apiuser: AuthUser
261 261 :param repoid: Optional, repository name or repository ID of the
262 262 target repository to which the |pr| is to be merged.
263 263 :type repoid: str or int
264 264 :param pullrequestid: ID of the pull request which shall be merged.
265 265 :type pullrequestid: int
266 266 :param userid: Merge the pull request as this user.
267 267 :type userid: Optional(str or int)
268 268
269 269 Example output:
270 270
271 271 .. code-block:: bash
272 272
273 273 "id": <id_given_in_input>,
274 274 "result": {
275 275 "executed": "<bool>",
276 276 "failure_reason": "<int>",
277 277 "merge_status_message": "<str>",
278 278 "merge_commit_id": "<merge_commit_id>",
279 279 "possible": "<bool>",
280 280 "merge_ref": {
281 281 "commit_id": "<commit_id>",
282 282 "type": "<type>",
283 283 "name": "<name>"
284 284 }
285 285 },
286 286 "error": null
287 287 """
288 288 pull_request = get_pull_request_or_error(pullrequestid)
289 289 if Optional.extract(repoid):
290 290 repo = get_repo_or_error(repoid)
291 291 else:
292 292 repo = pull_request.target_repo
293 293 auth_user = apiuser
294 294
295 295 if not isinstance(userid, Optional):
296 296 is_repo_admin = HasRepoPermissionAnyApi('repository.admin')(
297 297 user=apiuser, repo_name=repo.repo_name)
298 298 if has_superadmin_permission(apiuser) or is_repo_admin:
299 299 apiuser = get_user_or_error(userid)
300 300 auth_user = apiuser.AuthUser()
301 301 else:
302 302 raise JSONRPCError('userid is not the same as your user')
303 303
304 304 if pull_request.pull_request_state != PullRequest.STATE_CREATED:
305 305 raise JSONRPCError(
306 306 'Operation forbidden because pull request is in state {}, '
307 307 'only state {} is allowed.'.format(
308 308 pull_request.pull_request_state, PullRequest.STATE_CREATED))
309 309
310 310 with pull_request.set_state(PullRequest.STATE_UPDATING):
311 311 check = MergeCheck.validate(pull_request, auth_user=auth_user,
312 312 translator=request.translate)
313 313 merge_possible = not check.failed
314 314
315 315 if not merge_possible:
316 316 error_messages = []
317 317 for err_type, error_msg in check.errors:
318 318 error_msg = request.translate(error_msg)
319 319 error_messages.append(error_msg)
320 320
321 321 reasons = ','.join(error_messages)
322 322 raise JSONRPCError(
323 323 'merge not possible for following reasons: {}'.format(reasons))
324 324
325 325 target_repo = pull_request.target_repo
326 326 extras = vcs_operation_context(
327 327 request.environ, repo_name=target_repo.repo_name,
328 328 username=auth_user.username, action='push',
329 329 scm=target_repo.repo_type)
330 330 with pull_request.set_state(PullRequest.STATE_UPDATING):
331 331 merge_response = PullRequestModel().merge_repo(
332 332 pull_request, apiuser, extras=extras)
333 333 if merge_response.executed:
334 334 PullRequestModel().close_pull_request(pull_request.pull_request_id, auth_user)
335 335
336 336 Session().commit()
337 337
338 338 # In previous versions the merge response directly contained the merge
339 339 # commit id. It is now contained in the merge reference object. To be
340 340 # backwards compatible we have to extract it again.
341 341 merge_response = merge_response.asdict()
342 342 merge_response['merge_commit_id'] = merge_response['merge_ref'].commit_id
343 343
344 344 return merge_response
345 345
346 346
347 347 @jsonrpc_method()
348 348 def get_pull_request_comments(
349 349 request, apiuser, pullrequestid, repoid=Optional(None)):
350 350 """
351 351 Get all comments of pull request specified with the `pullrequestid`
352 352
353 353 :param apiuser: This is filled automatically from the |authtoken|.
354 354 :type apiuser: AuthUser
355 355 :param repoid: Optional repository name or repository ID.
356 356 :type repoid: str or int
357 357 :param pullrequestid: The pull request ID.
358 358 :type pullrequestid: int
359 359
360 360 Example output:
361 361
362 362 .. code-block:: bash
363 363
364 364 id : <id_given_in_input>
365 365 result : [
366 366 {
367 367 "comment_author": {
368 368 "active": true,
369 369 "full_name_or_username": "Tom Gore",
370 370 "username": "admin"
371 371 },
372 372 "comment_created_on": "2017-01-02T18:43:45.533",
373 373 "comment_f_path": null,
374 374 "comment_id": 25,
375 375 "comment_lineno": null,
376 376 "comment_status": {
377 377 "status": "under_review",
378 378 "status_lbl": "Under Review"
379 379 },
380 380 "comment_text": "Example text",
381 381 "comment_type": null,
382 382 "comment_last_version: 0,
383 383 "pull_request_version": null,
384 384 "comment_commit_id": None,
385 385 "comment_pull_request_id": <pull_request_id>
386 386 }
387 387 ],
388 388 error : null
389 389 """
390 390
391 391 pull_request = get_pull_request_or_error(pullrequestid)
392 392 if Optional.extract(repoid):
393 393 repo = get_repo_or_error(repoid)
394 394 else:
395 395 repo = pull_request.target_repo
396 396
397 397 if not PullRequestModel().check_user_read(
398 398 pull_request, apiuser, api=True):
399 399 raise JSONRPCError('repository `%s` or pull request `%s` '
400 400 'does not exist' % (repoid, pullrequestid))
401 401
402 402 (pull_request_latest,
403 403 pull_request_at_ver,
404 404 pull_request_display_obj,
405 405 at_version) = PullRequestModel().get_pr_version(
406 406 pull_request.pull_request_id, version=None)
407 407
408 408 versions = pull_request_display_obj.versions()
409 409 ver_map = {
410 410 ver.pull_request_version_id: cnt
411 411 for cnt, ver in enumerate(versions, 1)
412 412 }
413 413
414 414 # GENERAL COMMENTS with versions #
415 415 q = CommentsModel()._all_general_comments_of_pull_request(pull_request)
416 416 q = q.order_by(ChangesetComment.comment_id.asc())
417 417 general_comments = q.all()
418 418
419 419 # INLINE COMMENTS with versions #
420 420 q = CommentsModel()._all_inline_comments_of_pull_request(pull_request)
421 421 q = q.order_by(ChangesetComment.comment_id.asc())
422 422 inline_comments = q.all()
423 423
424 424 data = []
425 425 for comment in inline_comments + general_comments:
426 426 full_data = comment.get_api_data()
427 427 pr_version_id = None
428 428 if comment.pull_request_version_id:
429 429 pr_version_id = 'v{}'.format(
430 430 ver_map[comment.pull_request_version_id])
431 431
432 432 # sanitize some entries
433 433
434 434 full_data['pull_request_version'] = pr_version_id
435 435 full_data['comment_author'] = {
436 436 'username': full_data['comment_author'].username,
437 437 'full_name_or_username': full_data['comment_author'].full_name_or_username,
438 438 'active': full_data['comment_author'].active,
439 439 }
440 440
441 441 if full_data['comment_status']:
442 442 full_data['comment_status'] = {
443 443 'status': full_data['comment_status'][0].status,
444 444 'status_lbl': full_data['comment_status'][0].status_lbl,
445 445 }
446 446 else:
447 447 full_data['comment_status'] = {}
448 448
449 449 data.append(full_data)
450 450 return data
451 451
452 452
453 453 @jsonrpc_method()
454 454 def comment_pull_request(
455 455 request, apiuser, pullrequestid, repoid=Optional(None),
456 456 message=Optional(None), commit_id=Optional(None), status=Optional(None),
457 457 comment_type=Optional(ChangesetComment.COMMENT_TYPE_NOTE),
458 458 resolves_comment_id=Optional(None), extra_recipients=Optional([]),
459 459 userid=Optional(OAttr('apiuser')), send_email=Optional(True)):
460 460 """
461 461 Comment on the pull request specified with the `pullrequestid`,
462 462 in the |repo| specified by the `repoid`, and optionally change the
463 463 review status.
464 464
465 465 :param apiuser: This is filled automatically from the |authtoken|.
466 466 :type apiuser: AuthUser
467 467 :param repoid: Optional repository name or repository ID.
468 468 :type repoid: str or int
469 469 :param pullrequestid: The pull request ID.
470 470 :type pullrequestid: int
471 471 :param commit_id: Specify the commit_id for which to set a comment. If
472 472 given commit_id is different than latest in the PR status
473 473 change won't be performed.
474 474 :type commit_id: str
475 475 :param message: The text content of the comment.
476 476 :type message: str
477 477 :param status: (**Optional**) Set the approval status of the pull
478 478 request. One of: 'not_reviewed', 'approved', 'rejected',
479 479 'under_review'
480 480 :type status: str
481 481 :param comment_type: Comment type, one of: 'note', 'todo'
482 482 :type comment_type: Optional(str), default: 'note'
483 483 :param resolves_comment_id: id of comment which this one will resolve
484 484 :type resolves_comment_id: Optional(int)
485 485 :param extra_recipients: list of user ids or usernames to add
486 486 notifications for this comment. Acts like a CC for notification
487 487 :type extra_recipients: Optional(list)
488 488 :param userid: Comment on the pull request as this user
489 489 :type userid: Optional(str or int)
490 490 :param send_email: Define if this comment should also send email notification
491 491 :type send_email: Optional(bool)
492 492
493 493 Example output:
494 494
495 495 .. code-block:: bash
496 496
497 497 id : <id_given_in_input>
498 498 result : {
499 499 "pull_request_id": "<Integer>",
500 500 "comment_id": "<Integer>",
501 501 "status": {"given": <given_status>,
502 502 "was_changed": <bool status_was_actually_changed> },
503 503 },
504 504 error : null
505 505 """
506 506 _ = request.translate
507 507
508 508 pull_request = get_pull_request_or_error(pullrequestid)
509 509 if Optional.extract(repoid):
510 510 repo = get_repo_or_error(repoid)
511 511 else:
512 512 repo = pull_request.target_repo
513 513
514 514 db_repo_name = repo.repo_name
515 515 auth_user = apiuser
516 516 if not isinstance(userid, Optional):
517 517 is_repo_admin = HasRepoPermissionAnyApi('repository.admin')(
518 518 user=apiuser, repo_name=db_repo_name)
519 519 if has_superadmin_permission(apiuser) or is_repo_admin:
520 520 apiuser = get_user_or_error(userid)
521 521 auth_user = apiuser.AuthUser()
522 522 else:
523 523 raise JSONRPCError('userid is not the same as your user')
524 524
525 525 if pull_request.is_closed():
526 526 raise JSONRPCError(
527 527 'pull request `%s` comment failed, pull request is closed' % (
528 528 pullrequestid,))
529 529
530 530 if not PullRequestModel().check_user_read(
531 531 pull_request, apiuser, api=True):
532 532 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
533 533 message = Optional.extract(message)
534 534 status = Optional.extract(status)
535 535 commit_id = Optional.extract(commit_id)
536 536 comment_type = Optional.extract(comment_type)
537 537 resolves_comment_id = Optional.extract(resolves_comment_id)
538 538 extra_recipients = Optional.extract(extra_recipients)
539 539 send_email = Optional.extract(send_email, binary=True)
540 540
541 541 if not message and not status:
542 542 raise JSONRPCError(
543 543 'Both message and status parameters are missing. '
544 544 'At least one is required.')
545 545
546 546 if (status not in (st[0] for st in ChangesetStatus.STATUSES) and
547 547 status is not None):
548 548 raise JSONRPCError('Unknown comment status: `%s`' % status)
549 549
550 550 if commit_id and commit_id not in pull_request.revisions:
551 551 raise JSONRPCError(
552 552 'Invalid commit_id `%s` for this pull request.' % commit_id)
553 553
554 554 allowed_to_change_status = PullRequestModel().check_user_change_status(
555 555 pull_request, apiuser)
556 556
557 557 # if commit_id is passed re-validated if user is allowed to change status
558 558 # based on latest commit_id from the PR
559 559 if commit_id:
560 560 commit_idx = pull_request.revisions.index(commit_id)
561 561 if commit_idx != 0:
562 562 allowed_to_change_status = False
563 563
564 564 if resolves_comment_id:
565 565 comment = ChangesetComment.get(resolves_comment_id)
566 566 if not comment:
567 567 raise JSONRPCError(
568 568 'Invalid resolves_comment_id `%s` for this pull request.'
569 569 % resolves_comment_id)
570 570 if comment.comment_type != ChangesetComment.COMMENT_TYPE_TODO:
571 571 raise JSONRPCError(
572 572 'Comment `%s` is wrong type for setting status to resolved.'
573 573 % resolves_comment_id)
574 574
575 575 text = message
576 576 status_label = ChangesetStatus.get_status_lbl(status)
577 577 if status and allowed_to_change_status:
578 578 st_message = ('Status change %(transition_icon)s %(status)s'
579 579 % {'transition_icon': '>', 'status': status_label})
580 580 text = message or st_message
581 581
582 582 rc_config = SettingsModel().get_all_settings()
583 583 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
584 584
585 585 status_change = status and allowed_to_change_status
586 586 comment = CommentsModel().create(
587 587 text=text,
588 588 repo=pull_request.target_repo.repo_id,
589 589 user=apiuser.user_id,
590 590 pull_request=pull_request.pull_request_id,
591 591 f_path=None,
592 592 line_no=None,
593 593 status_change=(status_label if status_change else None),
594 594 status_change_type=(status if status_change else None),
595 595 closing_pr=False,
596 596 renderer=renderer,
597 597 comment_type=comment_type,
598 598 resolves_comment_id=resolves_comment_id,
599 599 auth_user=auth_user,
600 600 extra_recipients=extra_recipients,
601 601 send_email=send_email
602 602 )
603 603 is_inline = bool(comment.f_path and comment.line_no)
604 604
605 605 if allowed_to_change_status and status:
606 606 old_calculated_status = pull_request.calculated_review_status()
607 607 ChangesetStatusModel().set_status(
608 608 pull_request.target_repo.repo_id,
609 609 status,
610 610 apiuser.user_id,
611 611 comment,
612 612 pull_request=pull_request.pull_request_id
613 613 )
614 614 Session().flush()
615 615
616 616 Session().commit()
617 617
618 618 PullRequestModel().trigger_pull_request_hook(
619 619 pull_request, apiuser, 'comment',
620 620 data={'comment': comment})
621 621
622 622 if allowed_to_change_status and status:
623 623 # we now calculate the status of pull request, and based on that
624 624 # calculation we set the commits status
625 625 calculated_status = pull_request.calculated_review_status()
626 626 if old_calculated_status != calculated_status:
627 627 PullRequestModel().trigger_pull_request_hook(
628 628 pull_request, apiuser, 'review_status_change',
629 629 data={'status': calculated_status})
630 630
631 631 data = {
632 632 'pull_request_id': pull_request.pull_request_id,
633 633 'comment_id': comment.comment_id if comment else None,
634 634 'status': {'given': status, 'was_changed': status_change},
635 635 }
636 636
637 637 comment_broadcast_channel = channelstream.comment_channel(
638 638 db_repo_name, pull_request_obj=pull_request)
639 639
640 640 comment_data = data
641 641 comment_type = 'inline' if is_inline else 'general'
642 642 channelstream.comment_channelstream_push(
643 643 request, comment_broadcast_channel, apiuser,
644 644 _('posted a new {} comment').format(comment_type),
645 645 comment_data=comment_data)
646 646
647 647 return data
648 648
649 649
650 650 @jsonrpc_method()
651 651 def create_pull_request(
652 652 request, apiuser, source_repo, target_repo, source_ref, target_ref,
653 653 owner=Optional(OAttr('apiuser')), title=Optional(''), description=Optional(''),
654 654 description_renderer=Optional(''), reviewers=Optional(None)):
655 655 """
656 656 Creates a new pull request.
657 657
658 658 Accepts refs in the following formats:
659 659
660 660 * branch:<branch_name>:<sha>
661 661 * branch:<branch_name>
662 662 * bookmark:<bookmark_name>:<sha> (Mercurial only)
663 663 * bookmark:<bookmark_name> (Mercurial only)
664 664
665 665 :param apiuser: This is filled automatically from the |authtoken|.
666 666 :type apiuser: AuthUser
667 667 :param source_repo: Set the source repository name.
668 668 :type source_repo: str
669 669 :param target_repo: Set the target repository name.
670 670 :type target_repo: str
671 671 :param source_ref: Set the source ref name.
672 672 :type source_ref: str
673 673 :param target_ref: Set the target ref name.
674 674 :type target_ref: str
675 675 :param owner: user_id or username
676 676 :type owner: Optional(str)
677 677 :param title: Optionally Set the pull request title, it's generated otherwise
678 678 :type title: str
679 679 :param description: Set the pull request description.
680 680 :type description: Optional(str)
681 681 :type description_renderer: Optional(str)
682 682 :param description_renderer: Set pull request renderer for the description.
683 683 It should be 'rst', 'markdown' or 'plain'. If not give default
684 684 system renderer will be used
685 685 :param reviewers: Set the new pull request reviewers list.
686 686 Reviewer defined by review rules will be added automatically to the
687 687 defined list.
688 688 :type reviewers: Optional(list)
689 689 Accepts username strings or objects of the format:
690 690
691 691 [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}]
692 692 """
693 693
694 694 source_db_repo = get_repo_or_error(source_repo)
695 695 target_db_repo = get_repo_or_error(target_repo)
696 696 if not has_superadmin_permission(apiuser):
697 697 _perms = ('repository.admin', 'repository.write', 'repository.read',)
698 698 validate_repo_permissions(apiuser, source_repo, source_db_repo, _perms)
699 699
700 700 owner = validate_set_owner_permissions(apiuser, owner)
701 701
702 702 full_source_ref = resolve_ref_or_error(source_ref, source_db_repo)
703 703 full_target_ref = resolve_ref_or_error(target_ref, target_db_repo)
704 704
705 705 source_commit = get_commit_or_error(full_source_ref, source_db_repo)
706 706 target_commit = get_commit_or_error(full_target_ref, target_db_repo)
707 707
708 708 reviewer_objects = Optional.extract(reviewers) or []
709 709
710 710 # serialize and validate passed in given reviewers
711 711 if reviewer_objects:
712 712 schema = ReviewerListSchema()
713 713 try:
714 714 reviewer_objects = schema.deserialize(reviewer_objects)
715 715 except Invalid as err:
716 716 raise JSONRPCValidationError(colander_exc=err)
717 717
718 718 # validate users
719 719 for reviewer_object in reviewer_objects:
720 720 user = get_user_or_error(reviewer_object['username'])
721 721 reviewer_object['user_id'] = user.user_id
722 722
723 723 get_default_reviewers_data, validate_default_reviewers, validate_observers = \
724 724 PullRequestModel().get_reviewer_functions()
725 725
726 726 # recalculate reviewers logic, to make sure we can validate this
727 727 default_reviewers_data = get_default_reviewers_data(
728 owner, source_db_repo,
729 source_commit, target_db_repo, target_commit)
728 owner,
729 source_repo,
730 Reference(source_type, source_name, source_commit_id),
731 target_repo,
732 Reference(target_type, target_name, target_commit_id)
733 )
730 734
731 735 # now MERGE our given with the calculated
732 736 reviewer_objects = default_reviewers_data['reviewers'] + reviewer_objects
733 737
734 738 try:
735 739 reviewers = validate_default_reviewers(
736 740 reviewer_objects, default_reviewers_data)
737 741 except ValueError as e:
738 742 raise JSONRPCError('Reviewers Validation: {}'.format(e))
739 743
740 744 title = Optional.extract(title)
741 745 if not title:
742 746 title_source_ref = source_ref.split(':', 2)[1]
743 747 title = PullRequestModel().generate_pullrequest_title(
744 748 source=source_repo,
745 749 source_ref=title_source_ref,
746 750 target=target_repo
747 751 )
748 752
749 753 diff_info = default_reviewers_data['diff_info']
750 754 common_ancestor_id = diff_info['ancestor']
751 755 commits = diff_info['commits']
752 756
753 757 if not common_ancestor_id:
754 758 raise JSONRPCError('no common ancestor found')
755 759
756 760 if not commits:
757 761 raise JSONRPCError('no commits found')
758 762
759 763 # NOTE(marcink): reversed is consistent with how we open it in the WEB interface
760 764 revisions = [commit.raw_id for commit in reversed(commits)]
761 765
762 766 # recalculate target ref based on ancestor
763 767 target_ref_type, target_ref_name, __ = full_target_ref.split(':')
764 768 full_target_ref = ':'.join((target_ref_type, target_ref_name, common_ancestor_id))
765 769
766 770 # fetch renderer, if set fallback to plain in case of PR
767 771 rc_config = SettingsModel().get_all_settings()
768 772 default_system_renderer = rc_config.get('rhodecode_markup_renderer', 'plain')
769 773 description = Optional.extract(description)
770 774 description_renderer = Optional.extract(description_renderer) or default_system_renderer
771 775
772 776 pull_request = PullRequestModel().create(
773 777 created_by=owner.user_id,
774 778 source_repo=source_repo,
775 779 source_ref=full_source_ref,
776 780 target_repo=target_repo,
777 781 target_ref=full_target_ref,
778 782 common_ancestor_id=common_ancestor_id,
779 783 revisions=revisions,
780 784 reviewers=reviewers,
781 785 title=title,
782 786 description=description,
783 787 description_renderer=description_renderer,
784 788 reviewer_data=default_reviewers_data,
785 789 auth_user=apiuser
786 790 )
787 791
788 792 Session().commit()
789 793 data = {
790 794 'msg': 'Created new pull request `{}`'.format(title),
791 795 'pull_request_id': pull_request.pull_request_id,
792 796 }
793 797 return data
794 798
795 799
796 800 @jsonrpc_method()
797 801 def update_pull_request(
798 802 request, apiuser, pullrequestid, repoid=Optional(None),
799 803 title=Optional(''), description=Optional(''), description_renderer=Optional(''),
800 804 reviewers=Optional(None), update_commits=Optional(None)):
801 805 """
802 806 Updates a pull request.
803 807
804 808 :param apiuser: This is filled automatically from the |authtoken|.
805 809 :type apiuser: AuthUser
806 810 :param repoid: Optional repository name or repository ID.
807 811 :type repoid: str or int
808 812 :param pullrequestid: The pull request ID.
809 813 :type pullrequestid: int
810 814 :param title: Set the pull request title.
811 815 :type title: str
812 816 :param description: Update pull request description.
813 817 :type description: Optional(str)
814 818 :type description_renderer: Optional(str)
815 819 :param description_renderer: Update pull request renderer for the description.
816 820 It should be 'rst', 'markdown' or 'plain'
817 821 :param reviewers: Update pull request reviewers list with new value.
818 822 :type reviewers: Optional(list)
819 823 Accepts username strings or objects of the format:
820 824
821 825 [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}]
822 826
823 827 :param update_commits: Trigger update of commits for this pull request
824 828 :type: update_commits: Optional(bool)
825 829
826 830 Example output:
827 831
828 832 .. code-block:: bash
829 833
830 834 id : <id_given_in_input>
831 835 result : {
832 836 "msg": "Updated pull request `63`",
833 837 "pull_request": <pull_request_object>,
834 838 "updated_reviewers": {
835 839 "added": [
836 840 "username"
837 841 ],
838 842 "removed": []
839 843 },
840 844 "updated_commits": {
841 845 "added": [
842 846 "<sha1_hash>"
843 847 ],
844 848 "common": [
845 849 "<sha1_hash>",
846 850 "<sha1_hash>",
847 851 ],
848 852 "removed": []
849 853 }
850 854 }
851 855 error : null
852 856 """
853 857
854 858 pull_request = get_pull_request_or_error(pullrequestid)
855 859 if Optional.extract(repoid):
856 860 repo = get_repo_or_error(repoid)
857 861 else:
858 862 repo = pull_request.target_repo
859 863
860 864 if not PullRequestModel().check_user_update(
861 865 pull_request, apiuser, api=True):
862 866 raise JSONRPCError(
863 867 'pull request `%s` update failed, no permission to update.' % (
864 868 pullrequestid,))
865 869 if pull_request.is_closed():
866 870 raise JSONRPCError(
867 871 'pull request `%s` update failed, pull request is closed' % (
868 872 pullrequestid,))
869 873
870 874 reviewer_objects = Optional.extract(reviewers) or []
871 875
872 876 if reviewer_objects:
873 877 schema = ReviewerListSchema()
874 878 try:
875 879 reviewer_objects = schema.deserialize(reviewer_objects)
876 880 except Invalid as err:
877 881 raise JSONRPCValidationError(colander_exc=err)
878 882
879 883 # validate users
880 884 for reviewer_object in reviewer_objects:
881 885 user = get_user_or_error(reviewer_object['username'])
882 886 reviewer_object['user_id'] = user.user_id
883 887
884 888 get_default_reviewers_data, get_validated_reviewers, validate_observers = \
885 889 PullRequestModel().get_reviewer_functions()
886 890
887 891 # re-use stored rules
888 892 reviewer_rules = pull_request.reviewer_data
889 893 try:
890 894 reviewers = get_validated_reviewers(reviewer_objects, reviewer_rules)
891 895 except ValueError as e:
892 896 raise JSONRPCError('Reviewers Validation: {}'.format(e))
893 897 else:
894 898 reviewers = []
895 899
896 900 title = Optional.extract(title)
897 901 description = Optional.extract(description)
898 902 description_renderer = Optional.extract(description_renderer)
899 903
900 904 # Update title/description
901 905 title_changed = False
902 906 if title or description:
903 907 PullRequestModel().edit(
904 908 pull_request,
905 909 title or pull_request.title,
906 910 description or pull_request.description,
907 911 description_renderer or pull_request.description_renderer,
908 912 apiuser)
909 913 Session().commit()
910 914 title_changed = True
911 915
912 916 commit_changes = {"added": [], "common": [], "removed": []}
913 917
914 918 # Update commits
915 919 commits_changed = False
916 920 if str2bool(Optional.extract(update_commits)):
917 921
918 922 if pull_request.pull_request_state != PullRequest.STATE_CREATED:
919 923 raise JSONRPCError(
920 924 'Operation forbidden because pull request is in state {}, '
921 925 'only state {} is allowed.'.format(
922 926 pull_request.pull_request_state, PullRequest.STATE_CREATED))
923 927
924 928 with pull_request.set_state(PullRequest.STATE_UPDATING):
925 929 if PullRequestModel().has_valid_update_type(pull_request):
926 930 db_user = apiuser.get_instance()
927 931 update_response = PullRequestModel().update_commits(
928 932 pull_request, db_user)
929 933 commit_changes = update_response.changes or commit_changes
930 934 Session().commit()
931 935 commits_changed = True
932 936
933 937 # Update reviewers
934 938 reviewers_changed = False
935 939 reviewers_changes = {"added": [], "removed": []}
936 940 if reviewers:
937 941 old_calculated_status = pull_request.calculated_review_status()
938 942 added_reviewers, removed_reviewers = \
939 943 PullRequestModel().update_reviewers(pull_request, reviewers, apiuser)
940 944
941 945 reviewers_changes['added'] = sorted(
942 946 [get_user_or_error(n).username for n in added_reviewers])
943 947 reviewers_changes['removed'] = sorted(
944 948 [get_user_or_error(n).username for n in removed_reviewers])
945 949 Session().commit()
946 950
947 951 # trigger status changed if change in reviewers changes the status
948 952 calculated_status = pull_request.calculated_review_status()
949 953 if old_calculated_status != calculated_status:
950 954 PullRequestModel().trigger_pull_request_hook(
951 955 pull_request, apiuser, 'review_status_change',
952 956 data={'status': calculated_status})
953 957 reviewers_changed = True
954 958
955 959 observers_changed = False
956 960
957 961 # push changed to channelstream
958 962 if commits_changed or reviewers_changed or observers_changed:
959 963 pr_broadcast_channel = channelstream.pr_channel(pull_request)
960 964 msg = 'Pull request was updated.'
961 965 channelstream.pr_update_channelstream_push(
962 966 request, pr_broadcast_channel, apiuser, msg)
963 967
964 968 data = {
965 969 'msg': 'Updated pull request `{}`'.format(
966 970 pull_request.pull_request_id),
967 971 'pull_request': pull_request.get_api_data(),
968 972 'updated_commits': commit_changes,
969 973 'updated_reviewers': reviewers_changes
970 974 }
971 975
972 976 return data
973 977
974 978
975 979 @jsonrpc_method()
976 980 def close_pull_request(
977 981 request, apiuser, pullrequestid, repoid=Optional(None),
978 982 userid=Optional(OAttr('apiuser')), message=Optional('')):
979 983 """
980 984 Close the pull request specified by `pullrequestid`.
981 985
982 986 :param apiuser: This is filled automatically from the |authtoken|.
983 987 :type apiuser: AuthUser
984 988 :param repoid: Repository name or repository ID to which the pull
985 989 request belongs.
986 990 :type repoid: str or int
987 991 :param pullrequestid: ID of the pull request to be closed.
988 992 :type pullrequestid: int
989 993 :param userid: Close the pull request as this user.
990 994 :type userid: Optional(str or int)
991 995 :param message: Optional message to close the Pull Request with. If not
992 996 specified it will be generated automatically.
993 997 :type message: Optional(str)
994 998
995 999 Example output:
996 1000
997 1001 .. code-block:: bash
998 1002
999 1003 "id": <id_given_in_input>,
1000 1004 "result": {
1001 1005 "pull_request_id": "<int>",
1002 1006 "close_status": "<str:status_lbl>,
1003 1007 "closed": "<bool>"
1004 1008 },
1005 1009 "error": null
1006 1010
1007 1011 """
1008 1012 _ = request.translate
1009 1013
1010 1014 pull_request = get_pull_request_or_error(pullrequestid)
1011 1015 if Optional.extract(repoid):
1012 1016 repo = get_repo_or_error(repoid)
1013 1017 else:
1014 1018 repo = pull_request.target_repo
1015 1019
1016 1020 is_repo_admin = HasRepoPermissionAnyApi('repository.admin')(
1017 1021 user=apiuser, repo_name=repo.repo_name)
1018 1022 if not isinstance(userid, Optional):
1019 1023 if has_superadmin_permission(apiuser) or is_repo_admin:
1020 1024 apiuser = get_user_or_error(userid)
1021 1025 else:
1022 1026 raise JSONRPCError('userid is not the same as your user')
1023 1027
1024 1028 if pull_request.is_closed():
1025 1029 raise JSONRPCError(
1026 1030 'pull request `%s` is already closed' % (pullrequestid,))
1027 1031
1028 1032 # only owner or admin or person with write permissions
1029 1033 allowed_to_close = PullRequestModel().check_user_update(
1030 1034 pull_request, apiuser, api=True)
1031 1035
1032 1036 if not allowed_to_close:
1033 1037 raise JSONRPCError(
1034 1038 'pull request `%s` close failed, no permission to close.' % (
1035 1039 pullrequestid,))
1036 1040
1037 1041 # message we're using to close the PR, else it's automatically generated
1038 1042 message = Optional.extract(message)
1039 1043
1040 1044 # finally close the PR, with proper message comment
1041 1045 comment, status = PullRequestModel().close_pull_request_with_comment(
1042 1046 pull_request, apiuser, repo, message=message, auth_user=apiuser)
1043 1047 status_lbl = ChangesetStatus.get_status_lbl(status)
1044 1048
1045 1049 Session().commit()
1046 1050
1047 1051 data = {
1048 1052 'pull_request_id': pull_request.pull_request_id,
1049 1053 'close_status': status_lbl,
1050 1054 'closed': True,
1051 1055 }
1052 1056 return data
@@ -1,80 +1,82 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2020 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
23 23 from pyramid.view import view_config
24 24
25 25 from rhodecode.apps._base import RepoAppView
26 26 from rhodecode.apps.repository.utils import get_default_reviewers_data
27 27 from rhodecode.lib.auth import LoginRequired, HasRepoPermissionAnyDecorator
28 28 from rhodecode.lib.vcs.backends.base import Reference
29 29 from rhodecode.model.db import Repository
30 30
31 31 log = logging.getLogger(__name__)
32 32
33 33
34 34 class RepoReviewRulesView(RepoAppView):
35 35 def load_default_context(self):
36 36 c = self._get_local_tmpl_context()
37 37 return c
38 38
39 39 @LoginRequired()
40 40 @HasRepoPermissionAnyDecorator('repository.admin')
41 41 @view_config(
42 42 route_name='repo_reviewers', request_method='GET',
43 43 renderer='rhodecode:templates/admin/repos/repo_edit.mako')
44 44 def repo_review_rules(self):
45 45 c = self.load_default_context()
46 46 c.active = 'reviewers'
47 47
48 48 return self._get_template_context(c)
49 49
50 50 @LoginRequired()
51 51 @HasRepoPermissionAnyDecorator(
52 52 'repository.read', 'repository.write', 'repository.admin')
53 53 @view_config(
54 54 route_name='repo_default_reviewers_data', request_method='GET',
55 55 renderer='json_ext')
56 56 def repo_default_reviewers_data(self):
57 57 self.load_default_context()
58 58
59 59 request = self.request
60 60 source_repo = self.db_repo
61 61 source_repo_name = source_repo.repo_name
62 62 target_repo_name = request.GET.get('target_repo', source_repo_name)
63 63 target_repo = Repository.get_by_repo_name(target_repo_name)
64 64
65 65 current_user = request.user.get_instance()
66 66
67 67 source_commit_id = request.GET['source_ref']
68 68 source_type = request.GET['source_ref_type']
69 69 source_name = request.GET['source_ref_name']
70 70
71 71 target_commit_id = request.GET['target_ref']
72 72 target_type = request.GET['target_ref_type']
73 73 target_name = request.GET['target_ref_name']
74 74
75 source_ref = Reference(source_type, source_name, source_commit_id)
76 target_ref = Reference(target_type, target_name, target_commit_id)
77
78 75 review_data = get_default_reviewers_data(
79 current_user, source_repo, source_ref, target_repo, target_ref)
76 current_user,
77 source_repo,
78 Reference(source_type, source_name, source_commit_id),
79 target_repo,
80 Reference(target_type, target_name, target_commit_id)
81 )
80 82 return review_data
@@ -1,2205 +1,2229 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2020 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 os
30 30
31 31 import datetime
32 32 import urllib
33 33 import collections
34 34
35 35 from pyramid import compat
36 36 from pyramid.threadlocal import get_current_request
37 37
38 38 from rhodecode.lib.vcs.nodes import FileNode
39 39 from rhodecode.translation import lazy_ugettext
40 40 from rhodecode.lib import helpers as h, hooks_utils, diffs
41 41 from rhodecode.lib import audit_logger
42 42 from rhodecode.lib.compat import OrderedDict
43 43 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
44 44 from rhodecode.lib.markup_renderer import (
45 45 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
46 46 from rhodecode.lib.utils2 import (
47 47 safe_unicode, safe_str, md5_safe, AttributeDict, safe_int,
48 48 get_current_rhodecode_user)
49 49 from rhodecode.lib.vcs.backends.base import (
50 50 Reference, MergeResponse, MergeFailureReason, UpdateFailureReason,
51 51 TargetRefMissing, SourceRefMissing)
52 52 from rhodecode.lib.vcs.conf import settings as vcs_settings
53 53 from rhodecode.lib.vcs.exceptions import (
54 54 CommitDoesNotExistError, EmptyRepositoryError)
55 55 from rhodecode.model import BaseModel
56 56 from rhodecode.model.changeset_status import ChangesetStatusModel
57 57 from rhodecode.model.comment import CommentsModel
58 58 from rhodecode.model.db import (
59 59 or_, String, cast, PullRequest, PullRequestReviewers, ChangesetStatus,
60 60 PullRequestVersion, ChangesetComment, Repository, RepoReviewRule, User)
61 61 from rhodecode.model.meta import Session
62 62 from rhodecode.model.notification import NotificationModel, \
63 63 EmailNotificationModel
64 64 from rhodecode.model.scm import ScmModel
65 65 from rhodecode.model.settings import VcsSettingsModel
66 66
67 67
68 68 log = logging.getLogger(__name__)
69 69
70 70
71 71 # Data structure to hold the response data when updating commits during a pull
72 72 # request update.
73 73 class UpdateResponse(object):
74 74
75 75 def __init__(self, executed, reason, new, old, common_ancestor_id,
76 76 commit_changes, source_changed, target_changed):
77 77
78 78 self.executed = executed
79 79 self.reason = reason
80 80 self.new = new
81 81 self.old = old
82 82 self.common_ancestor_id = common_ancestor_id
83 83 self.changes = commit_changes
84 84 self.source_changed = source_changed
85 85 self.target_changed = target_changed
86 86
87 87
88 88 def get_diff_info(
89 89 source_repo, source_ref, target_repo, target_ref, get_authors=False,
90 90 get_commit_authors=True):
91 91 """
92 92 Calculates detailed diff information for usage in preview of creation of a pull-request.
93 93 This is also used for default reviewers logic
94 94 """
95 95
96 96 source_scm = source_repo.scm_instance()
97 97 target_scm = target_repo.scm_instance()
98 98
99 99 ancestor_id = target_scm.get_common_ancestor(target_ref, source_ref, source_scm)
100 100 if not ancestor_id:
101 101 raise ValueError(
102 102 'cannot calculate diff info without a common ancestor. '
103 103 'Make sure both repositories are related, and have a common forking commit.')
104 104
105 105 # case here is that want a simple diff without incoming commits,
106 106 # previewing what will be merged based only on commits in the source.
107 107 log.debug('Using ancestor %s as source_ref instead of %s',
108 108 ancestor_id, source_ref)
109 109
110 110 # source of changes now is the common ancestor
111 111 source_commit = source_scm.get_commit(commit_id=ancestor_id)
112 112 # target commit becomes the source ref as it is the last commit
113 113 # for diff generation this logic gives proper diff
114 114 target_commit = source_scm.get_commit(commit_id=source_ref)
115 115
116 116 vcs_diff = \
117 117 source_scm.get_diff(commit1=source_commit, commit2=target_commit,
118 118 ignore_whitespace=False, context=3)
119 119
120 120 diff_processor = diffs.DiffProcessor(
121 121 vcs_diff, format='newdiff', diff_limit=None,
122 122 file_limit=None, show_full_diff=True)
123 123
124 124 _parsed = diff_processor.prepare()
125 125
126 126 all_files = []
127 127 all_files_changes = []
128 128 changed_lines = {}
129 129 stats = [0, 0]
130 130 for f in _parsed:
131 131 all_files.append(f['filename'])
132 132 all_files_changes.append({
133 133 'filename': f['filename'],
134 134 'stats': f['stats']
135 135 })
136 136 stats[0] += f['stats']['added']
137 137 stats[1] += f['stats']['deleted']
138 138
139 139 changed_lines[f['filename']] = []
140 140 if len(f['chunks']) < 2:
141 141 continue
142 142 # first line is "context" information
143 143 for chunks in f['chunks'][1:]:
144 144 for chunk in chunks['lines']:
145 145 if chunk['action'] not in ('del', 'mod'):
146 146 continue
147 147 changed_lines[f['filename']].append(chunk['old_lineno'])
148 148
149 149 commit_authors = []
150 150 user_counts = {}
151 151 email_counts = {}
152 152 author_counts = {}
153 153 _commit_cache = {}
154 154
155 155 commits = []
156 156 if get_commit_authors:
157 157 log.debug('Obtaining commit authors from set of commits')
158 commits = target_scm.compare(
158 _compare_data = target_scm.compare(
159 159 target_ref, source_ref, source_scm, merge=True,
160 pre_load=["author", "date", "message", "branch", "parents"])
160 pre_load=["author", "date", "message"]
161 )
161 162
162 for commit in commits:
163 user = User.get_from_cs_author(commit.author)
163 for commit in _compare_data:
164 # NOTE(marcink): we serialize here, so we don't produce more vcsserver calls on data returned
165 # at this function which is later called via JSON serialization
166 serialized_commit = dict(
167 author=commit.author,
168 date=commit.date,
169 message=commit.message,
170 )
171 commits.append(serialized_commit)
172 user = User.get_from_cs_author(serialized_commit['author'])
164 173 if user and user not in commit_authors:
165 174 commit_authors.append(user)
166 175
167 176 # lines
168 177 if get_authors:
169 178 log.debug('Calculating authors of changed files')
170 179 target_commit = source_repo.get_commit(ancestor_id)
171 180
172 181 for fname, lines in changed_lines.items():
182
173 183 try:
174 node = target_commit.get_node(fname)
184 node = target_commit.get_node(fname, pre_load=["is_binary"])
175 185 except Exception:
186 log.exception("Failed to load node with path %s", fname)
176 187 continue
177 188
178 189 if not isinstance(node, FileNode):
179 190 continue
180 191
192 # NOTE(marcink): for binary node we don't do annotation, just use last author
193 if node.is_binary:
194 author = node.last_commit.author
195 email = node.last_commit.author_email
196
197 user = User.get_from_cs_author(author)
198 if user:
199 user_counts[user.user_id] = user_counts.get(user.user_id, 0) + 1
200 author_counts[author] = author_counts.get(author, 0) + 1
201 email_counts[email] = email_counts.get(email, 0) + 1
202
203 continue
204
181 205 for annotation in node.annotate:
182 206 line_no, commit_id, get_commit_func, line_text = annotation
183 207 if line_no in lines:
184 208 if commit_id not in _commit_cache:
185 209 _commit_cache[commit_id] = get_commit_func()
186 210 commit = _commit_cache[commit_id]
187 211 author = commit.author
188 212 email = commit.author_email
189 213 user = User.get_from_cs_author(author)
190 214 if user:
191 215 user_counts[user.user_id] = user_counts.get(user.user_id, 0) + 1
192 216 author_counts[author] = author_counts.get(author, 0) + 1
193 217 email_counts[email] = email_counts.get(email, 0) + 1
194 218
195 219 log.debug('Default reviewers processing finished')
196 220
197 221 return {
198 222 'commits': commits,
199 223 'files': all_files_changes,
200 224 'stats': stats,
201 225 'ancestor': ancestor_id,
202 226 # original authors of modified files
203 227 'original_authors': {
204 228 'users': user_counts,
205 229 'authors': author_counts,
206 230 'emails': email_counts,
207 231 },
208 232 'commit_authors': commit_authors
209 233 }
210 234
211 235
212 236 class PullRequestModel(BaseModel):
213 237
214 238 cls = PullRequest
215 239
216 240 DIFF_CONTEXT = diffs.DEFAULT_CONTEXT
217 241
218 242 UPDATE_STATUS_MESSAGES = {
219 243 UpdateFailureReason.NONE: lazy_ugettext(
220 244 'Pull request update successful.'),
221 245 UpdateFailureReason.UNKNOWN: lazy_ugettext(
222 246 'Pull request update failed because of an unknown error.'),
223 247 UpdateFailureReason.NO_CHANGE: lazy_ugettext(
224 248 'No update needed because the source and target have not changed.'),
225 249 UpdateFailureReason.WRONG_REF_TYPE: lazy_ugettext(
226 250 'Pull request cannot be updated because the reference type is '
227 251 'not supported for an update. Only Branch, Tag or Bookmark is allowed.'),
228 252 UpdateFailureReason.MISSING_TARGET_REF: lazy_ugettext(
229 253 'This pull request cannot be updated because the target '
230 254 'reference is missing.'),
231 255 UpdateFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
232 256 'This pull request cannot be updated because the source '
233 257 'reference is missing.'),
234 258 }
235 259 REF_TYPES = ['bookmark', 'book', 'tag', 'branch']
236 260 UPDATABLE_REF_TYPES = ['bookmark', 'book', 'branch']
237 261
238 262 def __get_pull_request(self, pull_request):
239 263 return self._get_instance((
240 264 PullRequest, PullRequestVersion), pull_request)
241 265
242 266 def _check_perms(self, perms, pull_request, user, api=False):
243 267 if not api:
244 268 return h.HasRepoPermissionAny(*perms)(
245 269 user=user, repo_name=pull_request.target_repo.repo_name)
246 270 else:
247 271 return h.HasRepoPermissionAnyApi(*perms)(
248 272 user=user, repo_name=pull_request.target_repo.repo_name)
249 273
250 274 def check_user_read(self, pull_request, user, api=False):
251 275 _perms = ('repository.admin', 'repository.write', 'repository.read',)
252 276 return self._check_perms(_perms, pull_request, user, api)
253 277
254 278 def check_user_merge(self, pull_request, user, api=False):
255 279 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
256 280 return self._check_perms(_perms, pull_request, user, api)
257 281
258 282 def check_user_update(self, pull_request, user, api=False):
259 283 owner = user.user_id == pull_request.user_id
260 284 return self.check_user_merge(pull_request, user, api) or owner
261 285
262 286 def check_user_delete(self, pull_request, user):
263 287 owner = user.user_id == pull_request.user_id
264 288 _perms = ('repository.admin',)
265 289 return self._check_perms(_perms, pull_request, user) or owner
266 290
267 291 def check_user_change_status(self, pull_request, user, api=False):
268 292 reviewer = user.user_id in [x.user_id for x in
269 293 pull_request.reviewers]
270 294 return self.check_user_update(pull_request, user, api) or reviewer
271 295
272 296 def check_user_comment(self, pull_request, user):
273 297 owner = user.user_id == pull_request.user_id
274 298 return self.check_user_read(pull_request, user) or owner
275 299
276 300 def get(self, pull_request):
277 301 return self.__get_pull_request(pull_request)
278 302
279 303 def _prepare_get_all_query(self, repo_name, search_q=None, source=False,
280 304 statuses=None, opened_by=None, order_by=None,
281 305 order_dir='desc', only_created=False):
282 306 repo = None
283 307 if repo_name:
284 308 repo = self._get_repo(repo_name)
285 309
286 310 q = PullRequest.query()
287 311
288 312 if search_q:
289 313 like_expression = u'%{}%'.format(safe_unicode(search_q))
290 314 q = q.join(User)
291 315 q = q.filter(or_(
292 316 cast(PullRequest.pull_request_id, String).ilike(like_expression),
293 317 User.username.ilike(like_expression),
294 318 PullRequest.title.ilike(like_expression),
295 319 PullRequest.description.ilike(like_expression),
296 320 ))
297 321
298 322 # source or target
299 323 if repo and source:
300 324 q = q.filter(PullRequest.source_repo == repo)
301 325 elif repo:
302 326 q = q.filter(PullRequest.target_repo == repo)
303 327
304 328 # closed,opened
305 329 if statuses:
306 330 q = q.filter(PullRequest.status.in_(statuses))
307 331
308 332 # opened by filter
309 333 if opened_by:
310 334 q = q.filter(PullRequest.user_id.in_(opened_by))
311 335
312 336 # only get those that are in "created" state
313 337 if only_created:
314 338 q = q.filter(PullRequest.pull_request_state == PullRequest.STATE_CREATED)
315 339
316 340 if order_by:
317 341 order_map = {
318 342 'name_raw': PullRequest.pull_request_id,
319 343 'id': PullRequest.pull_request_id,
320 344 'title': PullRequest.title,
321 345 'updated_on_raw': PullRequest.updated_on,
322 346 'target_repo': PullRequest.target_repo_id
323 347 }
324 348 if order_dir == 'asc':
325 349 q = q.order_by(order_map[order_by].asc())
326 350 else:
327 351 q = q.order_by(order_map[order_by].desc())
328 352
329 353 return q
330 354
331 355 def count_all(self, repo_name, search_q=None, source=False, statuses=None,
332 356 opened_by=None):
333 357 """
334 358 Count the number of pull requests for a specific repository.
335 359
336 360 :param repo_name: target or source repo
337 361 :param search_q: filter by text
338 362 :param source: boolean flag to specify if repo_name refers to source
339 363 :param statuses: list of pull request statuses
340 364 :param opened_by: author user of the pull request
341 365 :returns: int number of pull requests
342 366 """
343 367 q = self._prepare_get_all_query(
344 368 repo_name, search_q=search_q, source=source, statuses=statuses,
345 369 opened_by=opened_by)
346 370
347 371 return q.count()
348 372
349 373 def get_all(self, repo_name, search_q=None, source=False, statuses=None,
350 374 opened_by=None, offset=0, length=None, order_by=None, order_dir='desc'):
351 375 """
352 376 Get all pull requests for a specific repository.
353 377
354 378 :param repo_name: target or source repo
355 379 :param search_q: filter by text
356 380 :param source: boolean flag to specify if repo_name refers to source
357 381 :param statuses: list of pull request statuses
358 382 :param opened_by: author user of the pull request
359 383 :param offset: pagination offset
360 384 :param length: length of returned list
361 385 :param order_by: order of the returned list
362 386 :param order_dir: 'asc' or 'desc' ordering direction
363 387 :returns: list of pull requests
364 388 """
365 389 q = self._prepare_get_all_query(
366 390 repo_name, search_q=search_q, source=source, statuses=statuses,
367 391 opened_by=opened_by, order_by=order_by, order_dir=order_dir)
368 392
369 393 if length:
370 394 pull_requests = q.limit(length).offset(offset).all()
371 395 else:
372 396 pull_requests = q.all()
373 397
374 398 return pull_requests
375 399
376 400 def count_awaiting_review(self, repo_name, search_q=None, source=False, statuses=None,
377 401 opened_by=None):
378 402 """
379 403 Count the number of pull requests for a specific repository that are
380 404 awaiting review.
381 405
382 406 :param repo_name: target or source repo
383 407 :param search_q: filter by text
384 408 :param source: boolean flag to specify if repo_name refers to source
385 409 :param statuses: list of pull request statuses
386 410 :param opened_by: author user of the pull request
387 411 :returns: int number of pull requests
388 412 """
389 413 pull_requests = self.get_awaiting_review(
390 414 repo_name, search_q=search_q, source=source, statuses=statuses, opened_by=opened_by)
391 415
392 416 return len(pull_requests)
393 417
394 418 def get_awaiting_review(self, repo_name, search_q=None, source=False, statuses=None,
395 419 opened_by=None, offset=0, length=None,
396 420 order_by=None, order_dir='desc'):
397 421 """
398 422 Get all pull requests for a specific repository that are awaiting
399 423 review.
400 424
401 425 :param repo_name: target or source repo
402 426 :param search_q: filter by text
403 427 :param source: boolean flag to specify if repo_name refers to source
404 428 :param statuses: list of pull request statuses
405 429 :param opened_by: author user of the pull request
406 430 :param offset: pagination offset
407 431 :param length: length of returned list
408 432 :param order_by: order of the returned list
409 433 :param order_dir: 'asc' or 'desc' ordering direction
410 434 :returns: list of pull requests
411 435 """
412 436 pull_requests = self.get_all(
413 437 repo_name, search_q=search_q, source=source, statuses=statuses,
414 438 opened_by=opened_by, order_by=order_by, order_dir=order_dir)
415 439
416 440 _filtered_pull_requests = []
417 441 for pr in pull_requests:
418 442 status = pr.calculated_review_status()
419 443 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
420 444 ChangesetStatus.STATUS_UNDER_REVIEW]:
421 445 _filtered_pull_requests.append(pr)
422 446 if length:
423 447 return _filtered_pull_requests[offset:offset+length]
424 448 else:
425 449 return _filtered_pull_requests
426 450
427 451 def count_awaiting_my_review(self, repo_name, search_q=None, source=False, statuses=None,
428 452 opened_by=None, user_id=None):
429 453 """
430 454 Count the number of pull requests for a specific repository that are
431 455 awaiting review from a specific user.
432 456
433 457 :param repo_name: target or source repo
434 458 :param search_q: filter by text
435 459 :param source: boolean flag to specify if repo_name refers to source
436 460 :param statuses: list of pull request statuses
437 461 :param opened_by: author user of the pull request
438 462 :param user_id: reviewer user of the pull request
439 463 :returns: int number of pull requests
440 464 """
441 465 pull_requests = self.get_awaiting_my_review(
442 466 repo_name, search_q=search_q, source=source, statuses=statuses,
443 467 opened_by=opened_by, user_id=user_id)
444 468
445 469 return len(pull_requests)
446 470
447 471 def get_awaiting_my_review(self, repo_name, search_q=None, source=False, statuses=None,
448 472 opened_by=None, user_id=None, offset=0,
449 473 length=None, order_by=None, order_dir='desc'):
450 474 """
451 475 Get all pull requests for a specific repository that are awaiting
452 476 review from a specific user.
453 477
454 478 :param repo_name: target or source repo
455 479 :param search_q: filter by text
456 480 :param source: boolean flag to specify if repo_name refers to source
457 481 :param statuses: list of pull request statuses
458 482 :param opened_by: author user of the pull request
459 483 :param user_id: reviewer user of the pull request
460 484 :param offset: pagination offset
461 485 :param length: length of returned list
462 486 :param order_by: order of the returned list
463 487 :param order_dir: 'asc' or 'desc' ordering direction
464 488 :returns: list of pull requests
465 489 """
466 490 pull_requests = self.get_all(
467 491 repo_name, search_q=search_q, source=source, statuses=statuses,
468 492 opened_by=opened_by, order_by=order_by, order_dir=order_dir)
469 493
470 494 _my = PullRequestModel().get_not_reviewed(user_id)
471 495 my_participation = []
472 496 for pr in pull_requests:
473 497 if pr in _my:
474 498 my_participation.append(pr)
475 499 _filtered_pull_requests = my_participation
476 500 if length:
477 501 return _filtered_pull_requests[offset:offset+length]
478 502 else:
479 503 return _filtered_pull_requests
480 504
481 505 def get_not_reviewed(self, user_id):
482 506 return [
483 507 x.pull_request for x in PullRequestReviewers.query().filter(
484 508 PullRequestReviewers.user_id == user_id).all()
485 509 ]
486 510
487 511 def _prepare_participating_query(self, user_id=None, statuses=None, query='',
488 512 order_by=None, order_dir='desc'):
489 513 q = PullRequest.query()
490 514 if user_id:
491 515 reviewers_subquery = Session().query(
492 516 PullRequestReviewers.pull_request_id).filter(
493 517 PullRequestReviewers.user_id == user_id).subquery()
494 518 user_filter = or_(
495 519 PullRequest.user_id == user_id,
496 520 PullRequest.pull_request_id.in_(reviewers_subquery)
497 521 )
498 522 q = PullRequest.query().filter(user_filter)
499 523
500 524 # closed,opened
501 525 if statuses:
502 526 q = q.filter(PullRequest.status.in_(statuses))
503 527
504 528 if query:
505 529 like_expression = u'%{}%'.format(safe_unicode(query))
506 530 q = q.join(User)
507 531 q = q.filter(or_(
508 532 cast(PullRequest.pull_request_id, String).ilike(like_expression),
509 533 User.username.ilike(like_expression),
510 534 PullRequest.title.ilike(like_expression),
511 535 PullRequest.description.ilike(like_expression),
512 536 ))
513 537 if order_by:
514 538 order_map = {
515 539 'name_raw': PullRequest.pull_request_id,
516 540 'title': PullRequest.title,
517 541 'updated_on_raw': PullRequest.updated_on,
518 542 'target_repo': PullRequest.target_repo_id
519 543 }
520 544 if order_dir == 'asc':
521 545 q = q.order_by(order_map[order_by].asc())
522 546 else:
523 547 q = q.order_by(order_map[order_by].desc())
524 548
525 549 return q
526 550
527 551 def count_im_participating_in(self, user_id=None, statuses=None, query=''):
528 552 q = self._prepare_participating_query(user_id, statuses=statuses, query=query)
529 553 return q.count()
530 554
531 555 def get_im_participating_in(
532 556 self, user_id=None, statuses=None, query='', offset=0,
533 557 length=None, order_by=None, order_dir='desc'):
534 558 """
535 559 Get all Pull requests that i'm participating in, or i have opened
536 560 """
537 561
538 562 q = self._prepare_participating_query(
539 563 user_id, statuses=statuses, query=query, order_by=order_by,
540 564 order_dir=order_dir)
541 565
542 566 if length:
543 567 pull_requests = q.limit(length).offset(offset).all()
544 568 else:
545 569 pull_requests = q.all()
546 570
547 571 return pull_requests
548 572
549 573 def get_versions(self, pull_request):
550 574 """
551 575 returns version of pull request sorted by ID descending
552 576 """
553 577 return PullRequestVersion.query()\
554 578 .filter(PullRequestVersion.pull_request == pull_request)\
555 579 .order_by(PullRequestVersion.pull_request_version_id.asc())\
556 580 .all()
557 581
558 582 def get_pr_version(self, pull_request_id, version=None):
559 583 at_version = None
560 584
561 585 if version and version == 'latest':
562 586 pull_request_ver = PullRequest.get(pull_request_id)
563 587 pull_request_obj = pull_request_ver
564 588 _org_pull_request_obj = pull_request_obj
565 589 at_version = 'latest'
566 590 elif version:
567 591 pull_request_ver = PullRequestVersion.get_or_404(version)
568 592 pull_request_obj = pull_request_ver
569 593 _org_pull_request_obj = pull_request_ver.pull_request
570 594 at_version = pull_request_ver.pull_request_version_id
571 595 else:
572 596 _org_pull_request_obj = pull_request_obj = PullRequest.get_or_404(
573 597 pull_request_id)
574 598
575 599 pull_request_display_obj = PullRequest.get_pr_display_object(
576 600 pull_request_obj, _org_pull_request_obj)
577 601
578 602 return _org_pull_request_obj, pull_request_obj, \
579 603 pull_request_display_obj, at_version
580 604
581 605 def create(self, created_by, source_repo, source_ref, target_repo,
582 606 target_ref, revisions, reviewers, observers, title, description=None,
583 607 common_ancestor_id=None,
584 608 description_renderer=None,
585 609 reviewer_data=None, translator=None, auth_user=None):
586 610 translator = translator or get_current_request().translate
587 611
588 612 created_by_user = self._get_user(created_by)
589 613 auth_user = auth_user or created_by_user.AuthUser()
590 614 source_repo = self._get_repo(source_repo)
591 615 target_repo = self._get_repo(target_repo)
592 616
593 617 pull_request = PullRequest()
594 618 pull_request.source_repo = source_repo
595 619 pull_request.source_ref = source_ref
596 620 pull_request.target_repo = target_repo
597 621 pull_request.target_ref = target_ref
598 622 pull_request.revisions = revisions
599 623 pull_request.title = title
600 624 pull_request.description = description
601 625 pull_request.description_renderer = description_renderer
602 626 pull_request.author = created_by_user
603 627 pull_request.reviewer_data = reviewer_data
604 628 pull_request.pull_request_state = pull_request.STATE_CREATING
605 629 pull_request.common_ancestor_id = common_ancestor_id
606 630
607 631 Session().add(pull_request)
608 632 Session().flush()
609 633
610 634 reviewer_ids = set()
611 635 # members / reviewers
612 636 for reviewer_object in reviewers:
613 637 user_id, reasons, mandatory, role, rules = reviewer_object
614 638 user = self._get_user(user_id)
615 639
616 640 # skip duplicates
617 641 if user.user_id in reviewer_ids:
618 642 continue
619 643
620 644 reviewer_ids.add(user.user_id)
621 645
622 646 reviewer = PullRequestReviewers()
623 647 reviewer.user = user
624 648 reviewer.pull_request = pull_request
625 649 reviewer.reasons = reasons
626 650 reviewer.mandatory = mandatory
627 651 reviewer.role = role
628 652
629 653 # NOTE(marcink): pick only first rule for now
630 654 rule_id = list(rules)[0] if rules else None
631 655 rule = RepoReviewRule.get(rule_id) if rule_id else None
632 656 if rule:
633 657 review_group = rule.user_group_vote_rule(user_id)
634 658 # we check if this particular reviewer is member of a voting group
635 659 if review_group:
636 660 # NOTE(marcink):
637 661 # can be that user is member of more but we pick the first same,
638 662 # same as default reviewers algo
639 663 review_group = review_group[0]
640 664
641 665 rule_data = {
642 666 'rule_name':
643 667 rule.review_rule_name,
644 668 'rule_user_group_entry_id':
645 669 review_group.repo_review_rule_users_group_id,
646 670 'rule_user_group_name':
647 671 review_group.users_group.users_group_name,
648 672 'rule_user_group_members':
649 673 [x.user.username for x in review_group.users_group.members],
650 674 'rule_user_group_members_id':
651 675 [x.user.user_id for x in review_group.users_group.members],
652 676 }
653 677 # e.g {'vote_rule': -1, 'mandatory': True}
654 678 rule_data.update(review_group.rule_data())
655 679
656 680 reviewer.rule_data = rule_data
657 681
658 682 Session().add(reviewer)
659 683 Session().flush()
660 684
661 685 for observer_object in observers:
662 686 user_id, reasons, mandatory, role, rules = observer_object
663 687 user = self._get_user(user_id)
664 688
665 689 # skip duplicates from reviewers
666 690 if user.user_id in reviewer_ids:
667 691 continue
668 692
669 693 #reviewer_ids.add(user.user_id)
670 694
671 695 observer = PullRequestReviewers()
672 696 observer.user = user
673 697 observer.pull_request = pull_request
674 698 observer.reasons = reasons
675 699 observer.mandatory = mandatory
676 700 observer.role = role
677 701
678 702 # NOTE(marcink): pick only first rule for now
679 703 rule_id = list(rules)[0] if rules else None
680 704 rule = RepoReviewRule.get(rule_id) if rule_id else None
681 705 if rule:
682 706 # TODO(marcink): do we need this for observers ??
683 707 pass
684 708
685 709 Session().add(observer)
686 710 Session().flush()
687 711
688 712 # Set approval status to "Under Review" for all commits which are
689 713 # part of this pull request.
690 714 ChangesetStatusModel().set_status(
691 715 repo=target_repo,
692 716 status=ChangesetStatus.STATUS_UNDER_REVIEW,
693 717 user=created_by_user,
694 718 pull_request=pull_request
695 719 )
696 720 # we commit early at this point. This has to do with a fact
697 721 # that before queries do some row-locking. And because of that
698 722 # we need to commit and finish transaction before below validate call
699 723 # that for large repos could be long resulting in long row locks
700 724 Session().commit()
701 725
702 726 # prepare workspace, and run initial merge simulation. Set state during that
703 727 # operation
704 728 pull_request = PullRequest.get(pull_request.pull_request_id)
705 729
706 730 # set as merging, for merge simulation, and if finished to created so we mark
707 731 # simulation is working fine
708 732 with pull_request.set_state(PullRequest.STATE_MERGING,
709 733 final_state=PullRequest.STATE_CREATED) as state_obj:
710 734 MergeCheck.validate(
711 735 pull_request, auth_user=auth_user, translator=translator)
712 736
713 737 self.notify_reviewers(pull_request, reviewer_ids, created_by_user)
714 738 self.trigger_pull_request_hook(pull_request, created_by_user, 'create')
715 739
716 740 creation_data = pull_request.get_api_data(with_merge_state=False)
717 741 self._log_audit_action(
718 742 'repo.pull_request.create', {'data': creation_data},
719 743 auth_user, pull_request)
720 744
721 745 return pull_request
722 746
723 747 def trigger_pull_request_hook(self, pull_request, user, action, data=None):
724 748 pull_request = self.__get_pull_request(pull_request)
725 749 target_scm = pull_request.target_repo.scm_instance()
726 750 if action == 'create':
727 751 trigger_hook = hooks_utils.trigger_create_pull_request_hook
728 752 elif action == 'merge':
729 753 trigger_hook = hooks_utils.trigger_merge_pull_request_hook
730 754 elif action == 'close':
731 755 trigger_hook = hooks_utils.trigger_close_pull_request_hook
732 756 elif action == 'review_status_change':
733 757 trigger_hook = hooks_utils.trigger_review_pull_request_hook
734 758 elif action == 'update':
735 759 trigger_hook = hooks_utils.trigger_update_pull_request_hook
736 760 elif action == 'comment':
737 761 trigger_hook = hooks_utils.trigger_comment_pull_request_hook
738 762 elif action == 'comment_edit':
739 763 trigger_hook = hooks_utils.trigger_comment_pull_request_edit_hook
740 764 else:
741 765 return
742 766
743 767 log.debug('Handling pull_request %s trigger_pull_request_hook with action %s and hook: %s',
744 768 pull_request, action, trigger_hook)
745 769 trigger_hook(
746 770 username=user.username,
747 771 repo_name=pull_request.target_repo.repo_name,
748 772 repo_type=target_scm.alias,
749 773 pull_request=pull_request,
750 774 data=data)
751 775
752 776 def _get_commit_ids(self, pull_request):
753 777 """
754 778 Return the commit ids of the merged pull request.
755 779
756 780 This method is not dealing correctly yet with the lack of autoupdates
757 781 nor with the implicit target updates.
758 782 For example: if a commit in the source repo is already in the target it
759 783 will be reported anyways.
760 784 """
761 785 merge_rev = pull_request.merge_rev
762 786 if merge_rev is None:
763 787 raise ValueError('This pull request was not merged yet')
764 788
765 789 commit_ids = list(pull_request.revisions)
766 790 if merge_rev not in commit_ids:
767 791 commit_ids.append(merge_rev)
768 792
769 793 return commit_ids
770 794
771 795 def merge_repo(self, pull_request, user, extras):
772 796 log.debug("Merging pull request %s", pull_request.pull_request_id)
773 797 extras['user_agent'] = 'internal-merge'
774 798 merge_state = self._merge_pull_request(pull_request, user, extras)
775 799 if merge_state.executed:
776 800 log.debug("Merge was successful, updating the pull request comments.")
777 801 self._comment_and_close_pr(pull_request, user, merge_state)
778 802
779 803 self._log_audit_action(
780 804 'repo.pull_request.merge',
781 805 {'merge_state': merge_state.__dict__},
782 806 user, pull_request)
783 807
784 808 else:
785 809 log.warn("Merge failed, not updating the pull request.")
786 810 return merge_state
787 811
788 812 def _merge_pull_request(self, pull_request, user, extras, merge_msg=None):
789 813 target_vcs = pull_request.target_repo.scm_instance()
790 814 source_vcs = pull_request.source_repo.scm_instance()
791 815
792 816 message = safe_unicode(merge_msg or vcs_settings.MERGE_MESSAGE_TMPL).format(
793 817 pr_id=pull_request.pull_request_id,
794 818 pr_title=pull_request.title,
795 819 source_repo=source_vcs.name,
796 820 source_ref_name=pull_request.source_ref_parts.name,
797 821 target_repo=target_vcs.name,
798 822 target_ref_name=pull_request.target_ref_parts.name,
799 823 )
800 824
801 825 workspace_id = self._workspace_id(pull_request)
802 826 repo_id = pull_request.target_repo.repo_id
803 827 use_rebase = self._use_rebase_for_merging(pull_request)
804 828 close_branch = self._close_branch_before_merging(pull_request)
805 829 user_name = self._user_name_for_merging(pull_request, user)
806 830
807 831 target_ref = self._refresh_reference(
808 832 pull_request.target_ref_parts, target_vcs)
809 833
810 834 callback_daemon, extras = prepare_callback_daemon(
811 835 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
812 836 host=vcs_settings.HOOKS_HOST,
813 837 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
814 838
815 839 with callback_daemon:
816 840 # TODO: johbo: Implement a clean way to run a config_override
817 841 # for a single call.
818 842 target_vcs.config.set(
819 843 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
820 844
821 845 merge_state = target_vcs.merge(
822 846 repo_id, workspace_id, target_ref, source_vcs,
823 847 pull_request.source_ref_parts,
824 848 user_name=user_name, user_email=user.email,
825 849 message=message, use_rebase=use_rebase,
826 850 close_branch=close_branch)
827 851 return merge_state
828 852
829 853 def _comment_and_close_pr(self, pull_request, user, merge_state, close_msg=None):
830 854 pull_request.merge_rev = merge_state.merge_ref.commit_id
831 855 pull_request.updated_on = datetime.datetime.now()
832 856 close_msg = close_msg or 'Pull request merged and closed'
833 857
834 858 CommentsModel().create(
835 859 text=safe_unicode(close_msg),
836 860 repo=pull_request.target_repo.repo_id,
837 861 user=user.user_id,
838 862 pull_request=pull_request.pull_request_id,
839 863 f_path=None,
840 864 line_no=None,
841 865 closing_pr=True
842 866 )
843 867
844 868 Session().add(pull_request)
845 869 Session().flush()
846 870 # TODO: paris: replace invalidation with less radical solution
847 871 ScmModel().mark_for_invalidation(
848 872 pull_request.target_repo.repo_name)
849 873 self.trigger_pull_request_hook(pull_request, user, 'merge')
850 874
851 875 def has_valid_update_type(self, pull_request):
852 876 source_ref_type = pull_request.source_ref_parts.type
853 877 return source_ref_type in self.REF_TYPES
854 878
855 879 def get_flow_commits(self, pull_request):
856 880
857 881 # source repo
858 882 source_ref_name = pull_request.source_ref_parts.name
859 883 source_ref_type = pull_request.source_ref_parts.type
860 884 source_ref_id = pull_request.source_ref_parts.commit_id
861 885 source_repo = pull_request.source_repo.scm_instance()
862 886
863 887 try:
864 888 if source_ref_type in self.REF_TYPES:
865 889 source_commit = source_repo.get_commit(source_ref_name)
866 890 else:
867 891 source_commit = source_repo.get_commit(source_ref_id)
868 892 except CommitDoesNotExistError:
869 893 raise SourceRefMissing()
870 894
871 895 # target repo
872 896 target_ref_name = pull_request.target_ref_parts.name
873 897 target_ref_type = pull_request.target_ref_parts.type
874 898 target_ref_id = pull_request.target_ref_parts.commit_id
875 899 target_repo = pull_request.target_repo.scm_instance()
876 900
877 901 try:
878 902 if target_ref_type in self.REF_TYPES:
879 903 target_commit = target_repo.get_commit(target_ref_name)
880 904 else:
881 905 target_commit = target_repo.get_commit(target_ref_id)
882 906 except CommitDoesNotExistError:
883 907 raise TargetRefMissing()
884 908
885 909 return source_commit, target_commit
886 910
887 911 def update_commits(self, pull_request, updating_user):
888 912 """
889 913 Get the updated list of commits for the pull request
890 914 and return the new pull request version and the list
891 915 of commits processed by this update action
892 916
893 917 updating_user is the user_object who triggered the update
894 918 """
895 919 pull_request = self.__get_pull_request(pull_request)
896 920 source_ref_type = pull_request.source_ref_parts.type
897 921 source_ref_name = pull_request.source_ref_parts.name
898 922 source_ref_id = pull_request.source_ref_parts.commit_id
899 923
900 924 target_ref_type = pull_request.target_ref_parts.type
901 925 target_ref_name = pull_request.target_ref_parts.name
902 926 target_ref_id = pull_request.target_ref_parts.commit_id
903 927
904 928 if not self.has_valid_update_type(pull_request):
905 929 log.debug("Skipping update of pull request %s due to ref type: %s",
906 930 pull_request, source_ref_type)
907 931 return UpdateResponse(
908 932 executed=False,
909 933 reason=UpdateFailureReason.WRONG_REF_TYPE,
910 934 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
911 935 source_changed=False, target_changed=False)
912 936
913 937 try:
914 938 source_commit, target_commit = self.get_flow_commits(pull_request)
915 939 except SourceRefMissing:
916 940 return UpdateResponse(
917 941 executed=False,
918 942 reason=UpdateFailureReason.MISSING_SOURCE_REF,
919 943 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
920 944 source_changed=False, target_changed=False)
921 945 except TargetRefMissing:
922 946 return UpdateResponse(
923 947 executed=False,
924 948 reason=UpdateFailureReason.MISSING_TARGET_REF,
925 949 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
926 950 source_changed=False, target_changed=False)
927 951
928 952 source_changed = source_ref_id != source_commit.raw_id
929 953 target_changed = target_ref_id != target_commit.raw_id
930 954
931 955 if not (source_changed or target_changed):
932 956 log.debug("Nothing changed in pull request %s", pull_request)
933 957 return UpdateResponse(
934 958 executed=False,
935 959 reason=UpdateFailureReason.NO_CHANGE,
936 960 old=pull_request, new=None, common_ancestor_id=None, commit_changes=None,
937 961 source_changed=target_changed, target_changed=source_changed)
938 962
939 963 change_in_found = 'target repo' if target_changed else 'source repo'
940 964 log.debug('Updating pull request because of change in %s detected',
941 965 change_in_found)
942 966
943 967 # Finally there is a need for an update, in case of source change
944 968 # we create a new version, else just an update
945 969 if source_changed:
946 970 pull_request_version = self._create_version_from_snapshot(pull_request)
947 971 self._link_comments_to_version(pull_request_version)
948 972 else:
949 973 try:
950 974 ver = pull_request.versions[-1]
951 975 except IndexError:
952 976 ver = None
953 977
954 978 pull_request.pull_request_version_id = \
955 979 ver.pull_request_version_id if ver else None
956 980 pull_request_version = pull_request
957 981
958 982 source_repo = pull_request.source_repo.scm_instance()
959 983 target_repo = pull_request.target_repo.scm_instance()
960 984
961 985 # re-compute commit ids
962 986 old_commit_ids = pull_request.revisions
963 987 pre_load = ["author", "date", "message", "branch"]
964 988 commit_ranges = target_repo.compare(
965 989 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
966 990 pre_load=pre_load)
967 991
968 992 target_ref = target_commit.raw_id
969 993 source_ref = source_commit.raw_id
970 994 ancestor_commit_id = target_repo.get_common_ancestor(
971 995 target_ref, source_ref, source_repo)
972 996
973 997 if not ancestor_commit_id:
974 998 raise ValueError(
975 999 'cannot calculate diff info without a common ancestor. '
976 1000 'Make sure both repositories are related, and have a common forking commit.')
977 1001
978 1002 pull_request.common_ancestor_id = ancestor_commit_id
979 1003
980 1004 pull_request.source_ref = '%s:%s:%s' % (
981 1005 source_ref_type, source_ref_name, source_commit.raw_id)
982 1006 pull_request.target_ref = '%s:%s:%s' % (
983 1007 target_ref_type, target_ref_name, ancestor_commit_id)
984 1008
985 1009 pull_request.revisions = [
986 1010 commit.raw_id for commit in reversed(commit_ranges)]
987 1011 pull_request.updated_on = datetime.datetime.now()
988 1012 Session().add(pull_request)
989 1013 new_commit_ids = pull_request.revisions
990 1014
991 1015 old_diff_data, new_diff_data = self._generate_update_diffs(
992 1016 pull_request, pull_request_version)
993 1017
994 1018 # calculate commit and file changes
995 1019 commit_changes = self._calculate_commit_id_changes(
996 1020 old_commit_ids, new_commit_ids)
997 1021 file_changes = self._calculate_file_changes(
998 1022 old_diff_data, new_diff_data)
999 1023
1000 1024 # set comments as outdated if DIFFS changed
1001 1025 CommentsModel().outdate_comments(
1002 1026 pull_request, old_diff_data=old_diff_data,
1003 1027 new_diff_data=new_diff_data)
1004 1028
1005 1029 valid_commit_changes = (commit_changes.added or commit_changes.removed)
1006 1030 file_node_changes = (
1007 1031 file_changes.added or file_changes.modified or file_changes.removed)
1008 1032 pr_has_changes = valid_commit_changes or file_node_changes
1009 1033
1010 1034 # Add an automatic comment to the pull request, in case
1011 1035 # anything has changed
1012 1036 if pr_has_changes:
1013 1037 update_comment = CommentsModel().create(
1014 1038 text=self._render_update_message(ancestor_commit_id, commit_changes, file_changes),
1015 1039 repo=pull_request.target_repo,
1016 1040 user=pull_request.author,
1017 1041 pull_request=pull_request,
1018 1042 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
1019 1043
1020 1044 # Update status to "Under Review" for added commits
1021 1045 for commit_id in commit_changes.added:
1022 1046 ChangesetStatusModel().set_status(
1023 1047 repo=pull_request.source_repo,
1024 1048 status=ChangesetStatus.STATUS_UNDER_REVIEW,
1025 1049 comment=update_comment,
1026 1050 user=pull_request.author,
1027 1051 pull_request=pull_request,
1028 1052 revision=commit_id)
1029 1053
1030 1054 # send update email to users
1031 1055 try:
1032 1056 self.notify_users(pull_request=pull_request, updating_user=updating_user,
1033 1057 ancestor_commit_id=ancestor_commit_id,
1034 1058 commit_changes=commit_changes,
1035 1059 file_changes=file_changes)
1036 1060 except Exception:
1037 1061 log.exception('Failed to send email notification to users')
1038 1062
1039 1063 log.debug(
1040 1064 'Updated pull request %s, added_ids: %s, common_ids: %s, '
1041 1065 'removed_ids: %s', pull_request.pull_request_id,
1042 1066 commit_changes.added, commit_changes.common, commit_changes.removed)
1043 1067 log.debug(
1044 1068 'Updated pull request with the following file changes: %s',
1045 1069 file_changes)
1046 1070
1047 1071 log.info(
1048 1072 "Updated pull request %s from commit %s to commit %s, "
1049 1073 "stored new version %s of this pull request.",
1050 1074 pull_request.pull_request_id, source_ref_id,
1051 1075 pull_request.source_ref_parts.commit_id,
1052 1076 pull_request_version.pull_request_version_id)
1053 1077 Session().commit()
1054 1078 self.trigger_pull_request_hook(pull_request, pull_request.author, 'update')
1055 1079
1056 1080 return UpdateResponse(
1057 1081 executed=True, reason=UpdateFailureReason.NONE,
1058 1082 old=pull_request, new=pull_request_version,
1059 1083 common_ancestor_id=ancestor_commit_id, commit_changes=commit_changes,
1060 1084 source_changed=source_changed, target_changed=target_changed)
1061 1085
1062 1086 def _create_version_from_snapshot(self, pull_request):
1063 1087 version = PullRequestVersion()
1064 1088 version.title = pull_request.title
1065 1089 version.description = pull_request.description
1066 1090 version.status = pull_request.status
1067 1091 version.pull_request_state = pull_request.pull_request_state
1068 1092 version.created_on = datetime.datetime.now()
1069 1093 version.updated_on = pull_request.updated_on
1070 1094 version.user_id = pull_request.user_id
1071 1095 version.source_repo = pull_request.source_repo
1072 1096 version.source_ref = pull_request.source_ref
1073 1097 version.target_repo = pull_request.target_repo
1074 1098 version.target_ref = pull_request.target_ref
1075 1099
1076 1100 version._last_merge_source_rev = pull_request._last_merge_source_rev
1077 1101 version._last_merge_target_rev = pull_request._last_merge_target_rev
1078 1102 version.last_merge_status = pull_request.last_merge_status
1079 1103 version.last_merge_metadata = pull_request.last_merge_metadata
1080 1104 version.shadow_merge_ref = pull_request.shadow_merge_ref
1081 1105 version.merge_rev = pull_request.merge_rev
1082 1106 version.reviewer_data = pull_request.reviewer_data
1083 1107
1084 1108 version.revisions = pull_request.revisions
1085 1109 version.common_ancestor_id = pull_request.common_ancestor_id
1086 1110 version.pull_request = pull_request
1087 1111 Session().add(version)
1088 1112 Session().flush()
1089 1113
1090 1114 return version
1091 1115
1092 1116 def _generate_update_diffs(self, pull_request, pull_request_version):
1093 1117
1094 1118 diff_context = (
1095 1119 self.DIFF_CONTEXT +
1096 1120 CommentsModel.needed_extra_diff_context())
1097 1121 hide_whitespace_changes = False
1098 1122 source_repo = pull_request_version.source_repo
1099 1123 source_ref_id = pull_request_version.source_ref_parts.commit_id
1100 1124 target_ref_id = pull_request_version.target_ref_parts.commit_id
1101 1125 old_diff = self._get_diff_from_pr_or_version(
1102 1126 source_repo, source_ref_id, target_ref_id,
1103 1127 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1104 1128
1105 1129 source_repo = pull_request.source_repo
1106 1130 source_ref_id = pull_request.source_ref_parts.commit_id
1107 1131 target_ref_id = pull_request.target_ref_parts.commit_id
1108 1132
1109 1133 new_diff = self._get_diff_from_pr_or_version(
1110 1134 source_repo, source_ref_id, target_ref_id,
1111 1135 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1112 1136
1113 1137 old_diff_data = diffs.DiffProcessor(old_diff)
1114 1138 old_diff_data.prepare()
1115 1139 new_diff_data = diffs.DiffProcessor(new_diff)
1116 1140 new_diff_data.prepare()
1117 1141
1118 1142 return old_diff_data, new_diff_data
1119 1143
1120 1144 def _link_comments_to_version(self, pull_request_version):
1121 1145 """
1122 1146 Link all unlinked comments of this pull request to the given version.
1123 1147
1124 1148 :param pull_request_version: The `PullRequestVersion` to which
1125 1149 the comments shall be linked.
1126 1150
1127 1151 """
1128 1152 pull_request = pull_request_version.pull_request
1129 1153 comments = ChangesetComment.query()\
1130 1154 .filter(
1131 1155 # TODO: johbo: Should we query for the repo at all here?
1132 1156 # Pending decision on how comments of PRs are to be related
1133 1157 # to either the source repo, the target repo or no repo at all.
1134 1158 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
1135 1159 ChangesetComment.pull_request == pull_request,
1136 1160 ChangesetComment.pull_request_version == None)\
1137 1161 .order_by(ChangesetComment.comment_id.asc())
1138 1162
1139 1163 # TODO: johbo: Find out why this breaks if it is done in a bulk
1140 1164 # operation.
1141 1165 for comment in comments:
1142 1166 comment.pull_request_version_id = (
1143 1167 pull_request_version.pull_request_version_id)
1144 1168 Session().add(comment)
1145 1169
1146 1170 def _calculate_commit_id_changes(self, old_ids, new_ids):
1147 1171 added = [x for x in new_ids if x not in old_ids]
1148 1172 common = [x for x in new_ids if x in old_ids]
1149 1173 removed = [x for x in old_ids if x not in new_ids]
1150 1174 total = new_ids
1151 1175 return ChangeTuple(added, common, removed, total)
1152 1176
1153 1177 def _calculate_file_changes(self, old_diff_data, new_diff_data):
1154 1178
1155 1179 old_files = OrderedDict()
1156 1180 for diff_data in old_diff_data.parsed_diff:
1157 1181 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
1158 1182
1159 1183 added_files = []
1160 1184 modified_files = []
1161 1185 removed_files = []
1162 1186 for diff_data in new_diff_data.parsed_diff:
1163 1187 new_filename = diff_data['filename']
1164 1188 new_hash = md5_safe(diff_data['raw_diff'])
1165 1189
1166 1190 old_hash = old_files.get(new_filename)
1167 1191 if not old_hash:
1168 1192 # file is not present in old diff, we have to figure out from parsed diff
1169 1193 # operation ADD/REMOVE
1170 1194 operations_dict = diff_data['stats']['ops']
1171 1195 if diffs.DEL_FILENODE in operations_dict:
1172 1196 removed_files.append(new_filename)
1173 1197 else:
1174 1198 added_files.append(new_filename)
1175 1199 else:
1176 1200 if new_hash != old_hash:
1177 1201 modified_files.append(new_filename)
1178 1202 # now remove a file from old, since we have seen it already
1179 1203 del old_files[new_filename]
1180 1204
1181 1205 # removed files is when there are present in old, but not in NEW,
1182 1206 # since we remove old files that are present in new diff, left-overs
1183 1207 # if any should be the removed files
1184 1208 removed_files.extend(old_files.keys())
1185 1209
1186 1210 return FileChangeTuple(added_files, modified_files, removed_files)
1187 1211
1188 1212 def _render_update_message(self, ancestor_commit_id, changes, file_changes):
1189 1213 """
1190 1214 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
1191 1215 so it's always looking the same disregarding on which default
1192 1216 renderer system is using.
1193 1217
1194 1218 :param ancestor_commit_id: ancestor raw_id
1195 1219 :param changes: changes named tuple
1196 1220 :param file_changes: file changes named tuple
1197 1221
1198 1222 """
1199 1223 new_status = ChangesetStatus.get_status_lbl(
1200 1224 ChangesetStatus.STATUS_UNDER_REVIEW)
1201 1225
1202 1226 changed_files = (
1203 1227 file_changes.added + file_changes.modified + file_changes.removed)
1204 1228
1205 1229 params = {
1206 1230 'under_review_label': new_status,
1207 1231 'added_commits': changes.added,
1208 1232 'removed_commits': changes.removed,
1209 1233 'changed_files': changed_files,
1210 1234 'added_files': file_changes.added,
1211 1235 'modified_files': file_changes.modified,
1212 1236 'removed_files': file_changes.removed,
1213 1237 'ancestor_commit_id': ancestor_commit_id
1214 1238 }
1215 1239 renderer = RstTemplateRenderer()
1216 1240 return renderer.render('pull_request_update.mako', **params)
1217 1241
1218 1242 def edit(self, pull_request, title, description, description_renderer, user):
1219 1243 pull_request = self.__get_pull_request(pull_request)
1220 1244 old_data = pull_request.get_api_data(with_merge_state=False)
1221 1245 if pull_request.is_closed():
1222 1246 raise ValueError('This pull request is closed')
1223 1247 if title:
1224 1248 pull_request.title = title
1225 1249 pull_request.description = description
1226 1250 pull_request.updated_on = datetime.datetime.now()
1227 1251 pull_request.description_renderer = description_renderer
1228 1252 Session().add(pull_request)
1229 1253 self._log_audit_action(
1230 1254 'repo.pull_request.edit', {'old_data': old_data},
1231 1255 user, pull_request)
1232 1256
1233 1257 def update_reviewers(self, pull_request, reviewer_data, user):
1234 1258 """
1235 1259 Update the reviewers in the pull request
1236 1260
1237 1261 :param pull_request: the pr to update
1238 1262 :param reviewer_data: list of tuples
1239 1263 [(user, ['reason1', 'reason2'], mandatory_flag, role, [rules])]
1240 1264 :param user: current use who triggers this action
1241 1265 """
1242 1266
1243 1267 pull_request = self.__get_pull_request(pull_request)
1244 1268 if pull_request.is_closed():
1245 1269 raise ValueError('This pull request is closed')
1246 1270
1247 1271 reviewers = {}
1248 1272 for user_id, reasons, mandatory, role, rules in reviewer_data:
1249 1273 if isinstance(user_id, (int, compat.string_types)):
1250 1274 user_id = self._get_user(user_id).user_id
1251 1275 reviewers[user_id] = {
1252 1276 'reasons': reasons, 'mandatory': mandatory, 'role': role}
1253 1277
1254 1278 reviewers_ids = set(reviewers.keys())
1255 1279 current_reviewers = PullRequestReviewers.get_pull_request_reviewers(
1256 1280 pull_request.pull_request_id, role=PullRequestReviewers.ROLE_REVIEWER)
1257 1281
1258 1282 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
1259 1283
1260 1284 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
1261 1285 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
1262 1286
1263 1287 log.debug("Adding %s reviewers", ids_to_add)
1264 1288 log.debug("Removing %s reviewers", ids_to_remove)
1265 1289 changed = False
1266 1290 added_audit_reviewers = []
1267 1291 removed_audit_reviewers = []
1268 1292
1269 1293 for uid in ids_to_add:
1270 1294 changed = True
1271 1295 _usr = self._get_user(uid)
1272 1296 reviewer = PullRequestReviewers()
1273 1297 reviewer.user = _usr
1274 1298 reviewer.pull_request = pull_request
1275 1299 reviewer.reasons = reviewers[uid]['reasons']
1276 1300 # NOTE(marcink): mandatory shouldn't be changed now
1277 1301 # reviewer.mandatory = reviewers[uid]['reasons']
1278 1302 # NOTE(marcink): role should be hardcoded, so we won't edit it.
1279 1303 reviewer.role = PullRequestReviewers.ROLE_REVIEWER
1280 1304 Session().add(reviewer)
1281 1305 added_audit_reviewers.append(reviewer.get_dict())
1282 1306
1283 1307 for uid in ids_to_remove:
1284 1308 changed = True
1285 1309 # NOTE(marcink): we fetch "ALL" reviewers objects using .all().
1286 1310 # This is an edge case that handles previous state of having the same reviewer twice.
1287 1311 # this CAN happen due to the lack of DB checks
1288 1312 reviewers = PullRequestReviewers.query()\
1289 1313 .filter(PullRequestReviewers.user_id == uid,
1290 1314 PullRequestReviewers.role == PullRequestReviewers.ROLE_REVIEWER,
1291 1315 PullRequestReviewers.pull_request == pull_request)\
1292 1316 .all()
1293 1317
1294 1318 for obj in reviewers:
1295 1319 added_audit_reviewers.append(obj.get_dict())
1296 1320 Session().delete(obj)
1297 1321
1298 1322 if changed:
1299 1323 Session().expire_all()
1300 1324 pull_request.updated_on = datetime.datetime.now()
1301 1325 Session().add(pull_request)
1302 1326
1303 1327 # finally store audit logs
1304 1328 for user_data in added_audit_reviewers:
1305 1329 self._log_audit_action(
1306 1330 'repo.pull_request.reviewer.add', {'data': user_data},
1307 1331 user, pull_request)
1308 1332 for user_data in removed_audit_reviewers:
1309 1333 self._log_audit_action(
1310 1334 'repo.pull_request.reviewer.delete', {'old_data': user_data},
1311 1335 user, pull_request)
1312 1336
1313 1337 self.notify_reviewers(pull_request, ids_to_add, user.get_instance())
1314 1338 return ids_to_add, ids_to_remove
1315 1339
1316 1340 def update_observers(self, pull_request, observer_data, user):
1317 1341 """
1318 1342 Update the observers in the pull request
1319 1343
1320 1344 :param pull_request: the pr to update
1321 1345 :param observer_data: list of tuples
1322 1346 [(user, ['reason1', 'reason2'], mandatory_flag, role, [rules])]
1323 1347 :param user: current use who triggers this action
1324 1348 """
1325 1349 pull_request = self.__get_pull_request(pull_request)
1326 1350 if pull_request.is_closed():
1327 1351 raise ValueError('This pull request is closed')
1328 1352
1329 1353 observers = {}
1330 1354 for user_id, reasons, mandatory, role, rules in observer_data:
1331 1355 if isinstance(user_id, (int, compat.string_types)):
1332 1356 user_id = self._get_user(user_id).user_id
1333 1357 observers[user_id] = {
1334 1358 'reasons': reasons, 'observers': mandatory, 'role': role}
1335 1359
1336 1360 observers_ids = set(observers.keys())
1337 1361 current_observers = PullRequestReviewers.get_pull_request_reviewers(
1338 1362 pull_request.pull_request_id, role=PullRequestReviewers.ROLE_OBSERVER)
1339 1363
1340 1364 current_observers_ids = set([x.user.user_id for x in current_observers])
1341 1365
1342 1366 ids_to_add = observers_ids.difference(current_observers_ids)
1343 1367 ids_to_remove = current_observers_ids.difference(observers_ids)
1344 1368
1345 1369 log.debug("Adding %s observer", ids_to_add)
1346 1370 log.debug("Removing %s observer", ids_to_remove)
1347 1371 changed = False
1348 1372 added_audit_observers = []
1349 1373 removed_audit_observers = []
1350 1374
1351 1375 for uid in ids_to_add:
1352 1376 changed = True
1353 1377 _usr = self._get_user(uid)
1354 1378 observer = PullRequestReviewers()
1355 1379 observer.user = _usr
1356 1380 observer.pull_request = pull_request
1357 1381 observer.reasons = observers[uid]['reasons']
1358 1382 # NOTE(marcink): mandatory shouldn't be changed now
1359 1383 # observer.mandatory = observer[uid]['reasons']
1360 1384
1361 1385 # NOTE(marcink): role should be hardcoded, so we won't edit it.
1362 1386 observer.role = PullRequestReviewers.ROLE_OBSERVER
1363 1387 Session().add(observer)
1364 1388 added_audit_observers.append(observer.get_dict())
1365 1389
1366 1390 for uid in ids_to_remove:
1367 1391 changed = True
1368 1392 # NOTE(marcink): we fetch "ALL" reviewers objects using .all().
1369 1393 # This is an edge case that handles previous state of having the same reviewer twice.
1370 1394 # this CAN happen due to the lack of DB checks
1371 1395 observers = PullRequestReviewers.query()\
1372 1396 .filter(PullRequestReviewers.user_id == uid,
1373 1397 PullRequestReviewers.role == PullRequestReviewers.ROLE_OBSERVER,
1374 1398 PullRequestReviewers.pull_request == pull_request)\
1375 1399 .all()
1376 1400
1377 1401 for obj in observers:
1378 1402 added_audit_observers.append(obj.get_dict())
1379 1403 Session().delete(obj)
1380 1404
1381 1405 if changed:
1382 1406 Session().expire_all()
1383 1407 pull_request.updated_on = datetime.datetime.now()
1384 1408 Session().add(pull_request)
1385 1409
1386 1410 # finally store audit logs
1387 1411 for user_data in added_audit_observers:
1388 1412 self._log_audit_action(
1389 1413 'repo.pull_request.observer.add', {'data': user_data},
1390 1414 user, pull_request)
1391 1415 for user_data in removed_audit_observers:
1392 1416 self._log_audit_action(
1393 1417 'repo.pull_request.observer.delete', {'old_data': user_data},
1394 1418 user, pull_request)
1395 1419
1396 1420 self.notify_observers(pull_request, ids_to_add, user.get_instance())
1397 1421 return ids_to_add, ids_to_remove
1398 1422
1399 1423 def get_url(self, pull_request, request=None, permalink=False):
1400 1424 if not request:
1401 1425 request = get_current_request()
1402 1426
1403 1427 if permalink:
1404 1428 return request.route_url(
1405 1429 'pull_requests_global',
1406 1430 pull_request_id=pull_request.pull_request_id,)
1407 1431 else:
1408 1432 return request.route_url('pullrequest_show',
1409 1433 repo_name=safe_str(pull_request.target_repo.repo_name),
1410 1434 pull_request_id=pull_request.pull_request_id,)
1411 1435
1412 1436 def get_shadow_clone_url(self, pull_request, request=None):
1413 1437 """
1414 1438 Returns qualified url pointing to the shadow repository. If this pull
1415 1439 request is closed there is no shadow repository and ``None`` will be
1416 1440 returned.
1417 1441 """
1418 1442 if pull_request.is_closed():
1419 1443 return None
1420 1444 else:
1421 1445 pr_url = urllib.unquote(self.get_url(pull_request, request=request))
1422 1446 return safe_unicode('{pr_url}/repository'.format(pr_url=pr_url))
1423 1447
1424 1448 def _notify_reviewers(self, pull_request, user_ids, role, user):
1425 1449 # notification to reviewers/observers
1426 1450 if not user_ids:
1427 1451 return
1428 1452
1429 1453 log.debug('Notify following %s users about pull-request %s', role, user_ids)
1430 1454
1431 1455 pull_request_obj = pull_request
1432 1456 # get the current participants of this pull request
1433 1457 recipients = user_ids
1434 1458 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
1435 1459
1436 1460 pr_source_repo = pull_request_obj.source_repo
1437 1461 pr_target_repo = pull_request_obj.target_repo
1438 1462
1439 1463 pr_url = h.route_url('pullrequest_show',
1440 1464 repo_name=pr_target_repo.repo_name,
1441 1465 pull_request_id=pull_request_obj.pull_request_id,)
1442 1466
1443 1467 # set some variables for email notification
1444 1468 pr_target_repo_url = h.route_url(
1445 1469 'repo_summary', repo_name=pr_target_repo.repo_name)
1446 1470
1447 1471 pr_source_repo_url = h.route_url(
1448 1472 'repo_summary', repo_name=pr_source_repo.repo_name)
1449 1473
1450 1474 # pull request specifics
1451 1475 pull_request_commits = [
1452 1476 (x.raw_id, x.message)
1453 1477 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
1454 1478
1455 1479 current_rhodecode_user = user
1456 1480 kwargs = {
1457 1481 'user': current_rhodecode_user,
1458 1482 'pull_request_author': pull_request.author,
1459 1483 'pull_request': pull_request_obj,
1460 1484 'pull_request_commits': pull_request_commits,
1461 1485
1462 1486 'pull_request_target_repo': pr_target_repo,
1463 1487 'pull_request_target_repo_url': pr_target_repo_url,
1464 1488
1465 1489 'pull_request_source_repo': pr_source_repo,
1466 1490 'pull_request_source_repo_url': pr_source_repo_url,
1467 1491
1468 1492 'pull_request_url': pr_url,
1469 1493 'thread_ids': [pr_url],
1470 1494 'user_role': role
1471 1495 }
1472 1496
1473 1497 # pre-generate the subject for notification itself
1474 1498 (subject, _e, body_plaintext) = EmailNotificationModel().render_email(
1475 1499 notification_type, **kwargs)
1476 1500
1477 1501 # create notification objects, and emails
1478 1502 NotificationModel().create(
1479 1503 created_by=current_rhodecode_user,
1480 1504 notification_subject=subject,
1481 1505 notification_body=body_plaintext,
1482 1506 notification_type=notification_type,
1483 1507 recipients=recipients,
1484 1508 email_kwargs=kwargs,
1485 1509 )
1486 1510
1487 1511 def notify_reviewers(self, pull_request, reviewers_ids, user):
1488 1512 return self._notify_reviewers(pull_request, reviewers_ids,
1489 1513 PullRequestReviewers.ROLE_REVIEWER, user)
1490 1514
1491 1515 def notify_observers(self, pull_request, observers_ids, user):
1492 1516 return self._notify_reviewers(pull_request, observers_ids,
1493 1517 PullRequestReviewers.ROLE_OBSERVER, user)
1494 1518
1495 1519 def notify_users(self, pull_request, updating_user, ancestor_commit_id,
1496 1520 commit_changes, file_changes):
1497 1521
1498 1522 updating_user_id = updating_user.user_id
1499 1523 reviewers = set([x.user.user_id for x in pull_request.reviewers])
1500 1524 # NOTE(marcink): send notification to all other users except to
1501 1525 # person who updated the PR
1502 1526 recipients = reviewers.difference(set([updating_user_id]))
1503 1527
1504 1528 log.debug('Notify following recipients about pull-request update %s', recipients)
1505 1529
1506 1530 pull_request_obj = pull_request
1507 1531
1508 1532 # send email about the update
1509 1533 changed_files = (
1510 1534 file_changes.added + file_changes.modified + file_changes.removed)
1511 1535
1512 1536 pr_source_repo = pull_request_obj.source_repo
1513 1537 pr_target_repo = pull_request_obj.target_repo
1514 1538
1515 1539 pr_url = h.route_url('pullrequest_show',
1516 1540 repo_name=pr_target_repo.repo_name,
1517 1541 pull_request_id=pull_request_obj.pull_request_id,)
1518 1542
1519 1543 # set some variables for email notification
1520 1544 pr_target_repo_url = h.route_url(
1521 1545 'repo_summary', repo_name=pr_target_repo.repo_name)
1522 1546
1523 1547 pr_source_repo_url = h.route_url(
1524 1548 'repo_summary', repo_name=pr_source_repo.repo_name)
1525 1549
1526 1550 email_kwargs = {
1527 1551 'date': datetime.datetime.now(),
1528 1552 'updating_user': updating_user,
1529 1553
1530 1554 'pull_request': pull_request_obj,
1531 1555
1532 1556 'pull_request_target_repo': pr_target_repo,
1533 1557 'pull_request_target_repo_url': pr_target_repo_url,
1534 1558
1535 1559 'pull_request_source_repo': pr_source_repo,
1536 1560 'pull_request_source_repo_url': pr_source_repo_url,
1537 1561
1538 1562 'pull_request_url': pr_url,
1539 1563
1540 1564 'ancestor_commit_id': ancestor_commit_id,
1541 1565 'added_commits': commit_changes.added,
1542 1566 'removed_commits': commit_changes.removed,
1543 1567 'changed_files': changed_files,
1544 1568 'added_files': file_changes.added,
1545 1569 'modified_files': file_changes.modified,
1546 1570 'removed_files': file_changes.removed,
1547 1571 'thread_ids': [pr_url],
1548 1572 }
1549 1573
1550 1574 (subject, _e, body_plaintext) = EmailNotificationModel().render_email(
1551 1575 EmailNotificationModel.TYPE_PULL_REQUEST_UPDATE, **email_kwargs)
1552 1576
1553 1577 # create notification objects, and emails
1554 1578 NotificationModel().create(
1555 1579 created_by=updating_user,
1556 1580 notification_subject=subject,
1557 1581 notification_body=body_plaintext,
1558 1582 notification_type=EmailNotificationModel.TYPE_PULL_REQUEST_UPDATE,
1559 1583 recipients=recipients,
1560 1584 email_kwargs=email_kwargs,
1561 1585 )
1562 1586
1563 1587 def delete(self, pull_request, user=None):
1564 1588 if not user:
1565 1589 user = getattr(get_current_rhodecode_user(), 'username', None)
1566 1590
1567 1591 pull_request = self.__get_pull_request(pull_request)
1568 1592 old_data = pull_request.get_api_data(with_merge_state=False)
1569 1593 self._cleanup_merge_workspace(pull_request)
1570 1594 self._log_audit_action(
1571 1595 'repo.pull_request.delete', {'old_data': old_data},
1572 1596 user, pull_request)
1573 1597 Session().delete(pull_request)
1574 1598
1575 1599 def close_pull_request(self, pull_request, user):
1576 1600 pull_request = self.__get_pull_request(pull_request)
1577 1601 self._cleanup_merge_workspace(pull_request)
1578 1602 pull_request.status = PullRequest.STATUS_CLOSED
1579 1603 pull_request.updated_on = datetime.datetime.now()
1580 1604 Session().add(pull_request)
1581 1605 self.trigger_pull_request_hook(pull_request, pull_request.author, 'close')
1582 1606
1583 1607 pr_data = pull_request.get_api_data(with_merge_state=False)
1584 1608 self._log_audit_action(
1585 1609 'repo.pull_request.close', {'data': pr_data}, user, pull_request)
1586 1610
1587 1611 def close_pull_request_with_comment(
1588 1612 self, pull_request, user, repo, message=None, auth_user=None):
1589 1613
1590 1614 pull_request_review_status = pull_request.calculated_review_status()
1591 1615
1592 1616 if pull_request_review_status == ChangesetStatus.STATUS_APPROVED:
1593 1617 # approved only if we have voting consent
1594 1618 status = ChangesetStatus.STATUS_APPROVED
1595 1619 else:
1596 1620 status = ChangesetStatus.STATUS_REJECTED
1597 1621 status_lbl = ChangesetStatus.get_status_lbl(status)
1598 1622
1599 1623 default_message = (
1600 1624 'Closing with status change {transition_icon} {status}.'
1601 1625 ).format(transition_icon='>', status=status_lbl)
1602 1626 text = message or default_message
1603 1627
1604 1628 # create a comment, and link it to new status
1605 1629 comment = CommentsModel().create(
1606 1630 text=text,
1607 1631 repo=repo.repo_id,
1608 1632 user=user.user_id,
1609 1633 pull_request=pull_request.pull_request_id,
1610 1634 status_change=status_lbl,
1611 1635 status_change_type=status,
1612 1636 closing_pr=True,
1613 1637 auth_user=auth_user,
1614 1638 )
1615 1639
1616 1640 # calculate old status before we change it
1617 1641 old_calculated_status = pull_request.calculated_review_status()
1618 1642 ChangesetStatusModel().set_status(
1619 1643 repo.repo_id,
1620 1644 status,
1621 1645 user.user_id,
1622 1646 comment=comment,
1623 1647 pull_request=pull_request.pull_request_id
1624 1648 )
1625 1649
1626 1650 Session().flush()
1627 1651
1628 1652 self.trigger_pull_request_hook(pull_request, user, 'comment',
1629 1653 data={'comment': comment})
1630 1654
1631 1655 # we now calculate the status of pull request again, and based on that
1632 1656 # calculation trigger status change. This might happen in cases
1633 1657 # that non-reviewer admin closes a pr, which means his vote doesn't
1634 1658 # change the status, while if he's a reviewer this might change it.
1635 1659 calculated_status = pull_request.calculated_review_status()
1636 1660 if old_calculated_status != calculated_status:
1637 1661 self.trigger_pull_request_hook(pull_request, user, 'review_status_change',
1638 1662 data={'status': calculated_status})
1639 1663
1640 1664 # finally close the PR
1641 1665 PullRequestModel().close_pull_request(pull_request.pull_request_id, user)
1642 1666
1643 1667 return comment, status
1644 1668
1645 1669 def merge_status(self, pull_request, translator=None, force_shadow_repo_refresh=False):
1646 1670 _ = translator or get_current_request().translate
1647 1671
1648 1672 if not self._is_merge_enabled(pull_request):
1649 1673 return None, False, _('Server-side pull request merging is disabled.')
1650 1674
1651 1675 if pull_request.is_closed():
1652 1676 return None, False, _('This pull request is closed.')
1653 1677
1654 1678 merge_possible, msg = self._check_repo_requirements(
1655 1679 target=pull_request.target_repo, source=pull_request.source_repo,
1656 1680 translator=_)
1657 1681 if not merge_possible:
1658 1682 return None, merge_possible, msg
1659 1683
1660 1684 try:
1661 1685 merge_response = self._try_merge(
1662 1686 pull_request, force_shadow_repo_refresh=force_shadow_repo_refresh)
1663 1687 log.debug("Merge response: %s", merge_response)
1664 1688 return merge_response, merge_response.possible, merge_response.merge_status_message
1665 1689 except NotImplementedError:
1666 1690 return None, False, _('Pull request merging is not supported.')
1667 1691
1668 1692 def _check_repo_requirements(self, target, source, translator):
1669 1693 """
1670 1694 Check if `target` and `source` have compatible requirements.
1671 1695
1672 1696 Currently this is just checking for largefiles.
1673 1697 """
1674 1698 _ = translator
1675 1699 target_has_largefiles = self._has_largefiles(target)
1676 1700 source_has_largefiles = self._has_largefiles(source)
1677 1701 merge_possible = True
1678 1702 message = u''
1679 1703
1680 1704 if target_has_largefiles != source_has_largefiles:
1681 1705 merge_possible = False
1682 1706 if source_has_largefiles:
1683 1707 message = _(
1684 1708 'Target repository large files support is disabled.')
1685 1709 else:
1686 1710 message = _(
1687 1711 'Source repository large files support is disabled.')
1688 1712
1689 1713 return merge_possible, message
1690 1714
1691 1715 def _has_largefiles(self, repo):
1692 1716 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
1693 1717 'extensions', 'largefiles')
1694 1718 return largefiles_ui and largefiles_ui[0].active
1695 1719
1696 1720 def _try_merge(self, pull_request, force_shadow_repo_refresh=False):
1697 1721 """
1698 1722 Try to merge the pull request and return the merge status.
1699 1723 """
1700 1724 log.debug(
1701 1725 "Trying out if the pull request %s can be merged. Force_refresh=%s",
1702 1726 pull_request.pull_request_id, force_shadow_repo_refresh)
1703 1727 target_vcs = pull_request.target_repo.scm_instance()
1704 1728 # Refresh the target reference.
1705 1729 try:
1706 1730 target_ref = self._refresh_reference(
1707 1731 pull_request.target_ref_parts, target_vcs)
1708 1732 except CommitDoesNotExistError:
1709 1733 merge_state = MergeResponse(
1710 1734 False, False, None, MergeFailureReason.MISSING_TARGET_REF,
1711 1735 metadata={'target_ref': pull_request.target_ref_parts})
1712 1736 return merge_state
1713 1737
1714 1738 target_locked = pull_request.target_repo.locked
1715 1739 if target_locked and target_locked[0]:
1716 1740 locked_by = 'user:{}'.format(target_locked[0])
1717 1741 log.debug("The target repository is locked by %s.", locked_by)
1718 1742 merge_state = MergeResponse(
1719 1743 False, False, None, MergeFailureReason.TARGET_IS_LOCKED,
1720 1744 metadata={'locked_by': locked_by})
1721 1745 elif force_shadow_repo_refresh or self._needs_merge_state_refresh(
1722 1746 pull_request, target_ref):
1723 1747 log.debug("Refreshing the merge status of the repository.")
1724 1748 merge_state = self._refresh_merge_state(
1725 1749 pull_request, target_vcs, target_ref)
1726 1750 else:
1727 1751 possible = pull_request.last_merge_status == MergeFailureReason.NONE
1728 1752 metadata = {
1729 1753 'unresolved_files': '',
1730 1754 'target_ref': pull_request.target_ref_parts,
1731 1755 'source_ref': pull_request.source_ref_parts,
1732 1756 }
1733 1757 if pull_request.last_merge_metadata:
1734 1758 metadata.update(pull_request.last_merge_metadata_parsed)
1735 1759
1736 1760 if not possible and target_ref.type == 'branch':
1737 1761 # NOTE(marcink): case for mercurial multiple heads on branch
1738 1762 heads = target_vcs._heads(target_ref.name)
1739 1763 if len(heads) != 1:
1740 1764 heads = '\n,'.join(target_vcs._heads(target_ref.name))
1741 1765 metadata.update({
1742 1766 'heads': heads
1743 1767 })
1744 1768
1745 1769 merge_state = MergeResponse(
1746 1770 possible, False, None, pull_request.last_merge_status, metadata=metadata)
1747 1771
1748 1772 return merge_state
1749 1773
1750 1774 def _refresh_reference(self, reference, vcs_repository):
1751 1775 if reference.type in self.UPDATABLE_REF_TYPES:
1752 1776 name_or_id = reference.name
1753 1777 else:
1754 1778 name_or_id = reference.commit_id
1755 1779
1756 1780 refreshed_commit = vcs_repository.get_commit(name_or_id)
1757 1781 refreshed_reference = Reference(
1758 1782 reference.type, reference.name, refreshed_commit.raw_id)
1759 1783 return refreshed_reference
1760 1784
1761 1785 def _needs_merge_state_refresh(self, pull_request, target_reference):
1762 1786 return not(
1763 1787 pull_request.revisions and
1764 1788 pull_request.revisions[0] == pull_request._last_merge_source_rev and
1765 1789 target_reference.commit_id == pull_request._last_merge_target_rev)
1766 1790
1767 1791 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
1768 1792 workspace_id = self._workspace_id(pull_request)
1769 1793 source_vcs = pull_request.source_repo.scm_instance()
1770 1794 repo_id = pull_request.target_repo.repo_id
1771 1795 use_rebase = self._use_rebase_for_merging(pull_request)
1772 1796 close_branch = self._close_branch_before_merging(pull_request)
1773 1797 merge_state = target_vcs.merge(
1774 1798 repo_id, workspace_id,
1775 1799 target_reference, source_vcs, pull_request.source_ref_parts,
1776 1800 dry_run=True, use_rebase=use_rebase,
1777 1801 close_branch=close_branch)
1778 1802
1779 1803 # Do not store the response if there was an unknown error.
1780 1804 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
1781 1805 pull_request._last_merge_source_rev = \
1782 1806 pull_request.source_ref_parts.commit_id
1783 1807 pull_request._last_merge_target_rev = target_reference.commit_id
1784 1808 pull_request.last_merge_status = merge_state.failure_reason
1785 1809 pull_request.last_merge_metadata = merge_state.metadata
1786 1810
1787 1811 pull_request.shadow_merge_ref = merge_state.merge_ref
1788 1812 Session().add(pull_request)
1789 1813 Session().commit()
1790 1814
1791 1815 return merge_state
1792 1816
1793 1817 def _workspace_id(self, pull_request):
1794 1818 workspace_id = 'pr-%s' % pull_request.pull_request_id
1795 1819 return workspace_id
1796 1820
1797 1821 def generate_repo_data(self, repo, commit_id=None, branch=None,
1798 1822 bookmark=None, translator=None):
1799 1823 from rhodecode.model.repo import RepoModel
1800 1824
1801 1825 all_refs, selected_ref = \
1802 1826 self._get_repo_pullrequest_sources(
1803 1827 repo.scm_instance(), commit_id=commit_id,
1804 1828 branch=branch, bookmark=bookmark, translator=translator)
1805 1829
1806 1830 refs_select2 = []
1807 1831 for element in all_refs:
1808 1832 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1809 1833 refs_select2.append({'text': element[1], 'children': children})
1810 1834
1811 1835 return {
1812 1836 'user': {
1813 1837 'user_id': repo.user.user_id,
1814 1838 'username': repo.user.username,
1815 1839 'firstname': repo.user.first_name,
1816 1840 'lastname': repo.user.last_name,
1817 1841 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1818 1842 },
1819 1843 'name': repo.repo_name,
1820 1844 'link': RepoModel().get_url(repo),
1821 1845 'description': h.chop_at_smart(repo.description_safe, '\n'),
1822 1846 'refs': {
1823 1847 'all_refs': all_refs,
1824 1848 'selected_ref': selected_ref,
1825 1849 'select2_refs': refs_select2
1826 1850 }
1827 1851 }
1828 1852
1829 1853 def generate_pullrequest_title(self, source, source_ref, target):
1830 1854 return u'{source}#{at_ref} to {target}'.format(
1831 1855 source=source,
1832 1856 at_ref=source_ref,
1833 1857 target=target,
1834 1858 )
1835 1859
1836 1860 def _cleanup_merge_workspace(self, pull_request):
1837 1861 # Merging related cleanup
1838 1862 repo_id = pull_request.target_repo.repo_id
1839 1863 target_scm = pull_request.target_repo.scm_instance()
1840 1864 workspace_id = self._workspace_id(pull_request)
1841 1865
1842 1866 try:
1843 1867 target_scm.cleanup_merge_workspace(repo_id, workspace_id)
1844 1868 except NotImplementedError:
1845 1869 pass
1846 1870
1847 1871 def _get_repo_pullrequest_sources(
1848 1872 self, repo, commit_id=None, branch=None, bookmark=None,
1849 1873 translator=None):
1850 1874 """
1851 1875 Return a structure with repo's interesting commits, suitable for
1852 1876 the selectors in pullrequest controller
1853 1877
1854 1878 :param commit_id: a commit that must be in the list somehow
1855 1879 and selected by default
1856 1880 :param branch: a branch that must be in the list and selected
1857 1881 by default - even if closed
1858 1882 :param bookmark: a bookmark that must be in the list and selected
1859 1883 """
1860 1884 _ = translator or get_current_request().translate
1861 1885
1862 1886 commit_id = safe_str(commit_id) if commit_id else None
1863 1887 branch = safe_unicode(branch) if branch else None
1864 1888 bookmark = safe_unicode(bookmark) if bookmark else None
1865 1889
1866 1890 selected = None
1867 1891
1868 1892 # order matters: first source that has commit_id in it will be selected
1869 1893 sources = []
1870 1894 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1871 1895 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1872 1896
1873 1897 if commit_id:
1874 1898 ref_commit = (h.short_id(commit_id), commit_id)
1875 1899 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1876 1900
1877 1901 sources.append(
1878 1902 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1879 1903 )
1880 1904
1881 1905 groups = []
1882 1906
1883 1907 for group_key, ref_list, group_name, match in sources:
1884 1908 group_refs = []
1885 1909 for ref_name, ref_id in ref_list:
1886 1910 ref_key = u'{}:{}:{}'.format(group_key, ref_name, ref_id)
1887 1911 group_refs.append((ref_key, ref_name))
1888 1912
1889 1913 if not selected:
1890 1914 if set([commit_id, match]) & set([ref_id, ref_name]):
1891 1915 selected = ref_key
1892 1916
1893 1917 if group_refs:
1894 1918 groups.append((group_refs, group_name))
1895 1919
1896 1920 if not selected:
1897 1921 ref = commit_id or branch or bookmark
1898 1922 if ref:
1899 1923 raise CommitDoesNotExistError(
1900 1924 u'No commit refs could be found matching: {}'.format(ref))
1901 1925 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1902 1926 selected = u'branch:{}:{}'.format(
1903 1927 safe_unicode(repo.DEFAULT_BRANCH_NAME),
1904 1928 safe_unicode(repo.branches[repo.DEFAULT_BRANCH_NAME])
1905 1929 )
1906 1930 elif repo.commit_ids:
1907 1931 # make the user select in this case
1908 1932 selected = None
1909 1933 else:
1910 1934 raise EmptyRepositoryError()
1911 1935 return groups, selected
1912 1936
1913 1937 def get_diff(self, source_repo, source_ref_id, target_ref_id,
1914 1938 hide_whitespace_changes, diff_context):
1915 1939
1916 1940 return self._get_diff_from_pr_or_version(
1917 1941 source_repo, source_ref_id, target_ref_id,
1918 1942 hide_whitespace_changes=hide_whitespace_changes, diff_context=diff_context)
1919 1943
1920 1944 def _get_diff_from_pr_or_version(
1921 1945 self, source_repo, source_ref_id, target_ref_id,
1922 1946 hide_whitespace_changes, diff_context):
1923 1947
1924 1948 target_commit = source_repo.get_commit(
1925 1949 commit_id=safe_str(target_ref_id))
1926 1950 source_commit = source_repo.get_commit(
1927 1951 commit_id=safe_str(source_ref_id), maybe_unreachable=True)
1928 1952 if isinstance(source_repo, Repository):
1929 1953 vcs_repo = source_repo.scm_instance()
1930 1954 else:
1931 1955 vcs_repo = source_repo
1932 1956
1933 1957 # TODO: johbo: In the context of an update, we cannot reach
1934 1958 # the old commit anymore with our normal mechanisms. It needs
1935 1959 # some sort of special support in the vcs layer to avoid this
1936 1960 # workaround.
1937 1961 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1938 1962 vcs_repo.alias == 'git'):
1939 1963 source_commit.raw_id = safe_str(source_ref_id)
1940 1964
1941 1965 log.debug('calculating diff between '
1942 1966 'source_ref:%s and target_ref:%s for repo `%s`',
1943 1967 target_ref_id, source_ref_id,
1944 1968 safe_unicode(vcs_repo.path))
1945 1969
1946 1970 vcs_diff = vcs_repo.get_diff(
1947 1971 commit1=target_commit, commit2=source_commit,
1948 1972 ignore_whitespace=hide_whitespace_changes, context=diff_context)
1949 1973 return vcs_diff
1950 1974
1951 1975 def _is_merge_enabled(self, pull_request):
1952 1976 return self._get_general_setting(
1953 1977 pull_request, 'rhodecode_pr_merge_enabled')
1954 1978
1955 1979 def _use_rebase_for_merging(self, pull_request):
1956 1980 repo_type = pull_request.target_repo.repo_type
1957 1981 if repo_type == 'hg':
1958 1982 return self._get_general_setting(
1959 1983 pull_request, 'rhodecode_hg_use_rebase_for_merging')
1960 1984 elif repo_type == 'git':
1961 1985 return self._get_general_setting(
1962 1986 pull_request, 'rhodecode_git_use_rebase_for_merging')
1963 1987
1964 1988 return False
1965 1989
1966 1990 def _user_name_for_merging(self, pull_request, user):
1967 1991 env_user_name_attr = os.environ.get('RC_MERGE_USER_NAME_ATTR', '')
1968 1992 if env_user_name_attr and hasattr(user, env_user_name_attr):
1969 1993 user_name_attr = env_user_name_attr
1970 1994 else:
1971 1995 user_name_attr = 'short_contact'
1972 1996
1973 1997 user_name = getattr(user, user_name_attr)
1974 1998 return user_name
1975 1999
1976 2000 def _close_branch_before_merging(self, pull_request):
1977 2001 repo_type = pull_request.target_repo.repo_type
1978 2002 if repo_type == 'hg':
1979 2003 return self._get_general_setting(
1980 2004 pull_request, 'rhodecode_hg_close_branch_before_merging')
1981 2005 elif repo_type == 'git':
1982 2006 return self._get_general_setting(
1983 2007 pull_request, 'rhodecode_git_close_branch_before_merging')
1984 2008
1985 2009 return False
1986 2010
1987 2011 def _get_general_setting(self, pull_request, settings_key, default=False):
1988 2012 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1989 2013 settings = settings_model.get_general_settings()
1990 2014 return settings.get(settings_key, default)
1991 2015
1992 2016 def _log_audit_action(self, action, action_data, user, pull_request):
1993 2017 audit_logger.store(
1994 2018 action=action,
1995 2019 action_data=action_data,
1996 2020 user=user,
1997 2021 repo=pull_request.target_repo)
1998 2022
1999 2023 def get_reviewer_functions(self):
2000 2024 """
2001 2025 Fetches functions for validation and fetching default reviewers.
2002 2026 If available we use the EE package, else we fallback to CE
2003 2027 package functions
2004 2028 """
2005 2029 try:
2006 2030 from rc_reviewers.utils import get_default_reviewers_data
2007 2031 from rc_reviewers.utils import validate_default_reviewers
2008 2032 from rc_reviewers.utils import validate_observers
2009 2033 except ImportError:
2010 2034 from rhodecode.apps.repository.utils import get_default_reviewers_data
2011 2035 from rhodecode.apps.repository.utils import validate_default_reviewers
2012 2036 from rhodecode.apps.repository.utils import validate_observers
2013 2037
2014 2038 return get_default_reviewers_data, validate_default_reviewers, validate_observers
2015 2039
2016 2040
2017 2041 class MergeCheck(object):
2018 2042 """
2019 2043 Perform Merge Checks and returns a check object which stores information
2020 2044 about merge errors, and merge conditions
2021 2045 """
2022 2046 TODO_CHECK = 'todo'
2023 2047 PERM_CHECK = 'perm'
2024 2048 REVIEW_CHECK = 'review'
2025 2049 MERGE_CHECK = 'merge'
2026 2050 WIP_CHECK = 'wip'
2027 2051
2028 2052 def __init__(self):
2029 2053 self.review_status = None
2030 2054 self.merge_possible = None
2031 2055 self.merge_msg = ''
2032 2056 self.merge_response = None
2033 2057 self.failed = None
2034 2058 self.errors = []
2035 2059 self.error_details = OrderedDict()
2036 2060 self.source_commit = AttributeDict()
2037 2061 self.target_commit = AttributeDict()
2038 2062
2039 2063 def __repr__(self):
2040 2064 return '<MergeCheck(possible:{}, failed:{}, errors:{})>'.format(
2041 2065 self.merge_possible, self.failed, self.errors)
2042 2066
2043 2067 def push_error(self, error_type, message, error_key, details):
2044 2068 self.failed = True
2045 2069 self.errors.append([error_type, message])
2046 2070 self.error_details[error_key] = dict(
2047 2071 details=details,
2048 2072 error_type=error_type,
2049 2073 message=message
2050 2074 )
2051 2075
2052 2076 @classmethod
2053 2077 def validate(cls, pull_request, auth_user, translator, fail_early=False,
2054 2078 force_shadow_repo_refresh=False):
2055 2079 _ = translator
2056 2080 merge_check = cls()
2057 2081
2058 2082 # title has WIP:
2059 2083 if pull_request.work_in_progress:
2060 2084 log.debug("MergeCheck: cannot merge, title has wip: marker.")
2061 2085
2062 2086 msg = _('WIP marker in title prevents from accidental merge.')
2063 2087 merge_check.push_error('error', msg, cls.WIP_CHECK, pull_request.title)
2064 2088 if fail_early:
2065 2089 return merge_check
2066 2090
2067 2091 # permissions to merge
2068 2092 user_allowed_to_merge = PullRequestModel().check_user_merge(pull_request, auth_user)
2069 2093 if not user_allowed_to_merge:
2070 2094 log.debug("MergeCheck: cannot merge, approval is pending.")
2071 2095
2072 2096 msg = _('User `{}` not allowed to perform merge.').format(auth_user.username)
2073 2097 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
2074 2098 if fail_early:
2075 2099 return merge_check
2076 2100
2077 2101 # permission to merge into the target branch
2078 2102 target_commit_id = pull_request.target_ref_parts.commit_id
2079 2103 if pull_request.target_ref_parts.type == 'branch':
2080 2104 branch_name = pull_request.target_ref_parts.name
2081 2105 else:
2082 2106 # for mercurial we can always figure out the branch from the commit
2083 2107 # in case of bookmark
2084 2108 target_commit = pull_request.target_repo.get_commit(target_commit_id)
2085 2109 branch_name = target_commit.branch
2086 2110
2087 2111 rule, branch_perm = auth_user.get_rule_and_branch_permission(
2088 2112 pull_request.target_repo.repo_name, branch_name)
2089 2113 if branch_perm and branch_perm == 'branch.none':
2090 2114 msg = _('Target branch `{}` changes rejected by rule {}.').format(
2091 2115 branch_name, rule)
2092 2116 merge_check.push_error('error', msg, cls.PERM_CHECK, auth_user.username)
2093 2117 if fail_early:
2094 2118 return merge_check
2095 2119
2096 2120 # review status, must be always present
2097 2121 review_status = pull_request.calculated_review_status()
2098 2122 merge_check.review_status = review_status
2099 2123
2100 2124 status_approved = review_status == ChangesetStatus.STATUS_APPROVED
2101 2125 if not status_approved:
2102 2126 log.debug("MergeCheck: cannot merge, approval is pending.")
2103 2127
2104 2128 msg = _('Pull request reviewer approval is pending.')
2105 2129
2106 2130 merge_check.push_error('warning', msg, cls.REVIEW_CHECK, review_status)
2107 2131
2108 2132 if fail_early:
2109 2133 return merge_check
2110 2134
2111 2135 # left over TODOs
2112 2136 todos = CommentsModel().get_pull_request_unresolved_todos(pull_request)
2113 2137 if todos:
2114 2138 log.debug("MergeCheck: cannot merge, {} "
2115 2139 "unresolved TODOs left.".format(len(todos)))
2116 2140
2117 2141 if len(todos) == 1:
2118 2142 msg = _('Cannot merge, {} TODO still not resolved.').format(
2119 2143 len(todos))
2120 2144 else:
2121 2145 msg = _('Cannot merge, {} TODOs still not resolved.').format(
2122 2146 len(todos))
2123 2147
2124 2148 merge_check.push_error('warning', msg, cls.TODO_CHECK, todos)
2125 2149
2126 2150 if fail_early:
2127 2151 return merge_check
2128 2152
2129 2153 # merge possible, here is the filesystem simulation + shadow repo
2130 2154 merge_response, merge_status, msg = PullRequestModel().merge_status(
2131 2155 pull_request, translator=translator,
2132 2156 force_shadow_repo_refresh=force_shadow_repo_refresh)
2133 2157
2134 2158 merge_check.merge_possible = merge_status
2135 2159 merge_check.merge_msg = msg
2136 2160 merge_check.merge_response = merge_response
2137 2161
2138 2162 source_ref_id = pull_request.source_ref_parts.commit_id
2139 2163 target_ref_id = pull_request.target_ref_parts.commit_id
2140 2164
2141 2165 try:
2142 2166 source_commit, target_commit = PullRequestModel().get_flow_commits(pull_request)
2143 2167 merge_check.source_commit.changed = source_ref_id != source_commit.raw_id
2144 2168 merge_check.source_commit.ref_spec = pull_request.source_ref_parts
2145 2169 merge_check.source_commit.current_raw_id = source_commit.raw_id
2146 2170 merge_check.source_commit.previous_raw_id = source_ref_id
2147 2171
2148 2172 merge_check.target_commit.changed = target_ref_id != target_commit.raw_id
2149 2173 merge_check.target_commit.ref_spec = pull_request.target_ref_parts
2150 2174 merge_check.target_commit.current_raw_id = target_commit.raw_id
2151 2175 merge_check.target_commit.previous_raw_id = target_ref_id
2152 2176 except (SourceRefMissing, TargetRefMissing):
2153 2177 pass
2154 2178
2155 2179 if not merge_status:
2156 2180 log.debug("MergeCheck: cannot merge, pull request merge not possible.")
2157 2181 merge_check.push_error('warning', msg, cls.MERGE_CHECK, None)
2158 2182
2159 2183 if fail_early:
2160 2184 return merge_check
2161 2185
2162 2186 log.debug('MergeCheck: is failed: %s', merge_check.failed)
2163 2187 return merge_check
2164 2188
2165 2189 @classmethod
2166 2190 def get_merge_conditions(cls, pull_request, translator):
2167 2191 _ = translator
2168 2192 merge_details = {}
2169 2193
2170 2194 model = PullRequestModel()
2171 2195 use_rebase = model._use_rebase_for_merging(pull_request)
2172 2196
2173 2197 if use_rebase:
2174 2198 merge_details['merge_strategy'] = dict(
2175 2199 details={},
2176 2200 message=_('Merge strategy: rebase')
2177 2201 )
2178 2202 else:
2179 2203 merge_details['merge_strategy'] = dict(
2180 2204 details={},
2181 2205 message=_('Merge strategy: explicit merge commit')
2182 2206 )
2183 2207
2184 2208 close_branch = model._close_branch_before_merging(pull_request)
2185 2209 if close_branch:
2186 2210 repo_type = pull_request.target_repo.repo_type
2187 2211 close_msg = ''
2188 2212 if repo_type == 'hg':
2189 2213 close_msg = _('Source branch will be closed before the merge.')
2190 2214 elif repo_type == 'git':
2191 2215 close_msg = _('Source branch will be deleted after the merge.')
2192 2216
2193 2217 merge_details['close_branch'] = dict(
2194 2218 details={},
2195 2219 message=close_msg
2196 2220 )
2197 2221
2198 2222 return merge_details
2199 2223
2200 2224
2201 2225 ChangeTuple = collections.namedtuple(
2202 2226 'ChangeTuple', ['added', 'common', 'removed', 'total'])
2203 2227
2204 2228 FileChangeTuple = collections.namedtuple(
2205 2229 'FileChangeTuple', ['added', 'modified', 'removed'])
General Comments 0
You need to be logged in to leave comments. Login now