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