##// END OF EJS Templates
channelstream: cleanup, and re-organize code for posting comments/pr updated messages....
marcink -
r4505:7d9c5b92 stable
parent child Browse files
Show More
@@ -1,1017 +1,1052 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 import logging
23 23
24 24 from rhodecode.api import jsonrpc_method, JSONRPCError, JSONRPCValidationError
25 25 from rhodecode.api.utils import (
26 26 has_superadmin_permission, Optional, OAttr, get_repo_or_error,
27 27 get_pull_request_or_error, get_commit_or_error, get_user_or_error,
28 28 validate_repo_permissions, resolve_ref_or_error, validate_set_owner_permissions)
29 from rhodecode.lib import channelstream
29 30 from rhodecode.lib.auth import (HasRepoPermissionAnyApi)
30 31 from rhodecode.lib.base import vcs_operation_context
31 32 from rhodecode.lib.utils2 import str2bool
32 33 from rhodecode.model.changeset_status import ChangesetStatusModel
33 34 from rhodecode.model.comment import CommentsModel
34 35 from rhodecode.model.db import Session, ChangesetStatus, ChangesetComment, PullRequest
35 36 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
36 37 from rhodecode.model.settings import SettingsModel
37 38 from rhodecode.model.validation_schema import Invalid
38 39 from rhodecode.model.validation_schema.schemas.reviewer_schema import ReviewerListSchema
39 40
40 41 log = logging.getLogger(__name__)
41 42
42 43
43 44 @jsonrpc_method()
44 45 def get_pull_request(request, apiuser, pullrequestid, repoid=Optional(None),
45 46 merge_state=Optional(False)):
46 47 """
47 48 Get a pull request based on the given ID.
48 49
49 50 :param apiuser: This is filled automatically from the |authtoken|.
50 51 :type apiuser: AuthUser
51 52 :param repoid: Optional, repository name or repository ID from where
52 53 the pull request was opened.
53 54 :type repoid: str or int
54 55 :param pullrequestid: ID of the requested pull request.
55 56 :type pullrequestid: int
56 57 :param merge_state: Optional calculate merge state for each repository.
57 58 This could result in longer time to fetch the data
58 59 :type merge_state: bool
59 60
60 61 Example output:
61 62
62 63 .. code-block:: bash
63 64
64 65 "id": <id_given_in_input>,
65 66 "result":
66 67 {
67 68 "pull_request_id": "<pull_request_id>",
68 69 "url": "<url>",
69 70 "title": "<title>",
70 71 "description": "<description>",
71 72 "status" : "<status>",
72 73 "created_on": "<date_time_created>",
73 74 "updated_on": "<date_time_updated>",
74 75 "versions": "<number_or_versions_of_pr>",
75 76 "commit_ids": [
76 77 ...
77 78 "<commit_id>",
78 79 "<commit_id>",
79 80 ...
80 81 ],
81 82 "review_status": "<review_status>",
82 83 "mergeable": {
83 84 "status": "<bool>",
84 85 "message": "<message>",
85 86 },
86 87 "source": {
87 88 "clone_url": "<clone_url>",
88 89 "repository": "<repository_name>",
89 90 "reference":
90 91 {
91 92 "name": "<name>",
92 93 "type": "<type>",
93 94 "commit_id": "<commit_id>",
94 95 }
95 96 },
96 97 "target": {
97 98 "clone_url": "<clone_url>",
98 99 "repository": "<repository_name>",
99 100 "reference":
100 101 {
101 102 "name": "<name>",
102 103 "type": "<type>",
103 104 "commit_id": "<commit_id>",
104 105 }
105 106 },
106 107 "merge": {
107 108 "clone_url": "<clone_url>",
108 109 "reference":
109 110 {
110 111 "name": "<name>",
111 112 "type": "<type>",
112 113 "commit_id": "<commit_id>",
113 114 }
114 115 },
115 116 "author": <user_obj>,
116 117 "reviewers": [
117 118 ...
118 119 {
119 120 "user": "<user_obj>",
120 121 "review_status": "<review_status>",
121 122 }
122 123 ...
123 124 ]
124 125 },
125 126 "error": null
126 127 """
127 128
128 129 pull_request = get_pull_request_or_error(pullrequestid)
129 130 if Optional.extract(repoid):
130 131 repo = get_repo_or_error(repoid)
131 132 else:
132 133 repo = pull_request.target_repo
133 134
134 135 if not PullRequestModel().check_user_read(pull_request, apiuser, api=True):
135 136 raise JSONRPCError('repository `%s` or pull request `%s` '
136 137 'does not exist' % (repoid, pullrequestid))
137 138
138 139 # NOTE(marcink): only calculate and return merge state if the pr state is 'created'
139 140 # otherwise we can lock the repo on calculation of merge state while update/merge
140 141 # is happening.
141 142 pr_created = pull_request.pull_request_state == pull_request.STATE_CREATED
142 143 merge_state = Optional.extract(merge_state, binary=True) and pr_created
143 144 data = pull_request.get_api_data(with_merge_state=merge_state)
144 145 return data
145 146
146 147
147 148 @jsonrpc_method()
148 149 def get_pull_requests(request, apiuser, repoid, status=Optional('new'),
149 150 merge_state=Optional(False)):
150 151 """
151 152 Get all pull requests from the repository specified in `repoid`.
152 153
153 154 :param apiuser: This is filled automatically from the |authtoken|.
154 155 :type apiuser: AuthUser
155 156 :param repoid: Optional repository name or repository ID.
156 157 :type repoid: str or int
157 158 :param status: Only return pull requests with the specified status.
158 159 Valid options are.
159 160 * ``new`` (default)
160 161 * ``open``
161 162 * ``closed``
162 163 :type status: str
163 164 :param merge_state: Optional calculate merge state for each repository.
164 165 This could result in longer time to fetch the data
165 166 :type merge_state: bool
166 167
167 168 Example output:
168 169
169 170 .. code-block:: bash
170 171
171 172 "id": <id_given_in_input>,
172 173 "result":
173 174 [
174 175 ...
175 176 {
176 177 "pull_request_id": "<pull_request_id>",
177 178 "url": "<url>",
178 179 "title" : "<title>",
179 180 "description": "<description>",
180 181 "status": "<status>",
181 182 "created_on": "<date_time_created>",
182 183 "updated_on": "<date_time_updated>",
183 184 "commit_ids": [
184 185 ...
185 186 "<commit_id>",
186 187 "<commit_id>",
187 188 ...
188 189 ],
189 190 "review_status": "<review_status>",
190 191 "mergeable": {
191 192 "status": "<bool>",
192 193 "message: "<message>",
193 194 },
194 195 "source": {
195 196 "clone_url": "<clone_url>",
196 197 "reference":
197 198 {
198 199 "name": "<name>",
199 200 "type": "<type>",
200 201 "commit_id": "<commit_id>",
201 202 }
202 203 },
203 204 "target": {
204 205 "clone_url": "<clone_url>",
205 206 "reference":
206 207 {
207 208 "name": "<name>",
208 209 "type": "<type>",
209 210 "commit_id": "<commit_id>",
210 211 }
211 212 },
212 213 "merge": {
213 214 "clone_url": "<clone_url>",
214 215 "reference":
215 216 {
216 217 "name": "<name>",
217 218 "type": "<type>",
218 219 "commit_id": "<commit_id>",
219 220 }
220 221 },
221 222 "author": <user_obj>,
222 223 "reviewers": [
223 224 ...
224 225 {
225 226 "user": "<user_obj>",
226 227 "review_status": "<review_status>",
227 228 }
228 229 ...
229 230 ]
230 231 }
231 232 ...
232 233 ],
233 234 "error": null
234 235
235 236 """
236 237 repo = get_repo_or_error(repoid)
237 238 if not has_superadmin_permission(apiuser):
238 239 _perms = (
239 240 'repository.admin', 'repository.write', 'repository.read',)
240 241 validate_repo_permissions(apiuser, repoid, repo, _perms)
241 242
242 243 status = Optional.extract(status)
243 244 merge_state = Optional.extract(merge_state, binary=True)
244 245 pull_requests = PullRequestModel().get_all(repo, statuses=[status],
245 246 order_by='id', order_dir='desc')
246 247 data = [pr.get_api_data(with_merge_state=merge_state) for pr in pull_requests]
247 248 return data
248 249
249 250
250 251 @jsonrpc_method()
251 252 def merge_pull_request(
252 253 request, apiuser, pullrequestid, repoid=Optional(None),
253 254 userid=Optional(OAttr('apiuser'))):
254 255 """
255 256 Merge the pull request specified by `pullrequestid` into its target
256 257 repository.
257 258
258 259 :param apiuser: This is filled automatically from the |authtoken|.
259 260 :type apiuser: AuthUser
260 261 :param repoid: Optional, repository name or repository ID of the
261 262 target repository to which the |pr| is to be merged.
262 263 :type repoid: str or int
263 264 :param pullrequestid: ID of the pull request which shall be merged.
264 265 :type pullrequestid: int
265 266 :param userid: Merge the pull request as this user.
266 267 :type userid: Optional(str or int)
267 268
268 269 Example output:
269 270
270 271 .. code-block:: bash
271 272
272 273 "id": <id_given_in_input>,
273 274 "result": {
274 275 "executed": "<bool>",
275 276 "failure_reason": "<int>",
276 277 "merge_status_message": "<str>",
277 278 "merge_commit_id": "<merge_commit_id>",
278 279 "possible": "<bool>",
279 280 "merge_ref": {
280 281 "commit_id": "<commit_id>",
281 282 "type": "<type>",
282 283 "name": "<name>"
283 284 }
284 285 },
285 286 "error": null
286 287 """
287 288 pull_request = get_pull_request_or_error(pullrequestid)
288 289 if Optional.extract(repoid):
289 290 repo = get_repo_or_error(repoid)
290 291 else:
291 292 repo = pull_request.target_repo
292 293 auth_user = apiuser
293 294
294 295 if not isinstance(userid, Optional):
295 296 is_repo_admin = HasRepoPermissionAnyApi('repository.admin')(
296 297 user=apiuser, repo_name=repo.repo_name)
297 298 if has_superadmin_permission(apiuser) or is_repo_admin:
298 299 apiuser = get_user_or_error(userid)
299 300 auth_user = apiuser.AuthUser()
300 301 else:
301 302 raise JSONRPCError('userid is not the same as your user')
302 303
303 304 if pull_request.pull_request_state != PullRequest.STATE_CREATED:
304 305 raise JSONRPCError(
305 306 'Operation forbidden because pull request is in state {}, '
306 307 'only state {} is allowed.'.format(
307 308 pull_request.pull_request_state, PullRequest.STATE_CREATED))
308 309
309 310 with pull_request.set_state(PullRequest.STATE_UPDATING):
310 311 check = MergeCheck.validate(pull_request, auth_user=auth_user,
311 312 translator=request.translate)
312 313 merge_possible = not check.failed
313 314
314 315 if not merge_possible:
315 316 error_messages = []
316 317 for err_type, error_msg in check.errors:
317 318 error_msg = request.translate(error_msg)
318 319 error_messages.append(error_msg)
319 320
320 321 reasons = ','.join(error_messages)
321 322 raise JSONRPCError(
322 323 'merge not possible for following reasons: {}'.format(reasons))
323 324
324 325 target_repo = pull_request.target_repo
325 326 extras = vcs_operation_context(
326 327 request.environ, repo_name=target_repo.repo_name,
327 328 username=auth_user.username, action='push',
328 329 scm=target_repo.repo_type)
329 330 with pull_request.set_state(PullRequest.STATE_UPDATING):
330 331 merge_response = PullRequestModel().merge_repo(
331 332 pull_request, apiuser, extras=extras)
332 333 if merge_response.executed:
333 334 PullRequestModel().close_pull_request(pull_request.pull_request_id, auth_user)
334 335
335 336 Session().commit()
336 337
337 338 # In previous versions the merge response directly contained the merge
338 339 # commit id. It is now contained in the merge reference object. To be
339 340 # backwards compatible we have to extract it again.
340 341 merge_response = merge_response.asdict()
341 342 merge_response['merge_commit_id'] = merge_response['merge_ref'].commit_id
342 343
343 344 return merge_response
344 345
345 346
346 347 @jsonrpc_method()
347 348 def get_pull_request_comments(
348 349 request, apiuser, pullrequestid, repoid=Optional(None)):
349 350 """
350 351 Get all comments of pull request specified with the `pullrequestid`
351 352
352 353 :param apiuser: This is filled automatically from the |authtoken|.
353 354 :type apiuser: AuthUser
354 355 :param repoid: Optional repository name or repository ID.
355 356 :type repoid: str or int
356 357 :param pullrequestid: The pull request ID.
357 358 :type pullrequestid: int
358 359
359 360 Example output:
360 361
361 362 .. code-block:: bash
362 363
363 364 id : <id_given_in_input>
364 365 result : [
365 366 {
366 367 "comment_author": {
367 368 "active": true,
368 369 "full_name_or_username": "Tom Gore",
369 370 "username": "admin"
370 371 },
371 372 "comment_created_on": "2017-01-02T18:43:45.533",
372 373 "comment_f_path": null,
373 374 "comment_id": 25,
374 375 "comment_lineno": null,
375 376 "comment_status": {
376 377 "status": "under_review",
377 378 "status_lbl": "Under Review"
378 379 },
379 380 "comment_text": "Example text",
380 381 "comment_type": null,
381 382 "comment_last_version: 0,
382 383 "pull_request_version": null,
383 384 "comment_commit_id": None,
384 385 "comment_pull_request_id": <pull_request_id>
385 386 }
386 387 ],
387 388 error : null
388 389 """
389 390
390 391 pull_request = get_pull_request_or_error(pullrequestid)
391 392 if Optional.extract(repoid):
392 393 repo = get_repo_or_error(repoid)
393 394 else:
394 395 repo = pull_request.target_repo
395 396
396 397 if not PullRequestModel().check_user_read(
397 398 pull_request, apiuser, api=True):
398 399 raise JSONRPCError('repository `%s` or pull request `%s` '
399 400 'does not exist' % (repoid, pullrequestid))
400 401
401 402 (pull_request_latest,
402 403 pull_request_at_ver,
403 404 pull_request_display_obj,
404 405 at_version) = PullRequestModel().get_pr_version(
405 406 pull_request.pull_request_id, version=None)
406 407
407 408 versions = pull_request_display_obj.versions()
408 409 ver_map = {
409 410 ver.pull_request_version_id: cnt
410 411 for cnt, ver in enumerate(versions, 1)
411 412 }
412 413
413 414 # GENERAL COMMENTS with versions #
414 415 q = CommentsModel()._all_general_comments_of_pull_request(pull_request)
415 416 q = q.order_by(ChangesetComment.comment_id.asc())
416 417 general_comments = q.all()
417 418
418 419 # INLINE COMMENTS with versions #
419 420 q = CommentsModel()._all_inline_comments_of_pull_request(pull_request)
420 421 q = q.order_by(ChangesetComment.comment_id.asc())
421 422 inline_comments = q.all()
422 423
423 424 data = []
424 425 for comment in inline_comments + general_comments:
425 426 full_data = comment.get_api_data()
426 427 pr_version_id = None
427 428 if comment.pull_request_version_id:
428 429 pr_version_id = 'v{}'.format(
429 430 ver_map[comment.pull_request_version_id])
430 431
431 432 # sanitize some entries
432 433
433 434 full_data['pull_request_version'] = pr_version_id
434 435 full_data['comment_author'] = {
435 436 'username': full_data['comment_author'].username,
436 437 'full_name_or_username': full_data['comment_author'].full_name_or_username,
437 438 'active': full_data['comment_author'].active,
438 439 }
439 440
440 441 if full_data['comment_status']:
441 442 full_data['comment_status'] = {
442 443 'status': full_data['comment_status'][0].status,
443 444 'status_lbl': full_data['comment_status'][0].status_lbl,
444 445 }
445 446 else:
446 447 full_data['comment_status'] = {}
447 448
448 449 data.append(full_data)
449 450 return data
450 451
451 452
452 453 @jsonrpc_method()
453 454 def comment_pull_request(
454 455 request, apiuser, pullrequestid, repoid=Optional(None),
455 456 message=Optional(None), commit_id=Optional(None), status=Optional(None),
456 457 comment_type=Optional(ChangesetComment.COMMENT_TYPE_NOTE),
457 458 resolves_comment_id=Optional(None), extra_recipients=Optional([]),
458 459 userid=Optional(OAttr('apiuser')), send_email=Optional(True)):
459 460 """
460 461 Comment on the pull request specified with the `pullrequestid`,
461 462 in the |repo| specified by the `repoid`, and optionally change the
462 463 review status.
463 464
464 465 :param apiuser: This is filled automatically from the |authtoken|.
465 466 :type apiuser: AuthUser
466 467 :param repoid: Optional repository name or repository ID.
467 468 :type repoid: str or int
468 469 :param pullrequestid: The pull request ID.
469 470 :type pullrequestid: int
470 471 :param commit_id: Specify the commit_id for which to set a comment. If
471 472 given commit_id is different than latest in the PR status
472 473 change won't be performed.
473 474 :type commit_id: str
474 475 :param message: The text content of the comment.
475 476 :type message: str
476 477 :param status: (**Optional**) Set the approval status of the pull
477 478 request. One of: 'not_reviewed', 'approved', 'rejected',
478 479 'under_review'
479 480 :type status: str
480 481 :param comment_type: Comment type, one of: 'note', 'todo'
481 482 :type comment_type: Optional(str), default: 'note'
482 483 :param resolves_comment_id: id of comment which this one will resolve
483 484 :type resolves_comment_id: Optional(int)
484 485 :param extra_recipients: list of user ids or usernames to add
485 486 notifications for this comment. Acts like a CC for notification
486 487 :type extra_recipients: Optional(list)
487 488 :param userid: Comment on the pull request as this user
488 489 :type userid: Optional(str or int)
489 490 :param send_email: Define if this comment should also send email notification
490 491 :type send_email: Optional(bool)
491 492
492 493 Example output:
493 494
494 495 .. code-block:: bash
495 496
496 497 id : <id_given_in_input>
497 498 result : {
498 499 "pull_request_id": "<Integer>",
499 500 "comment_id": "<Integer>",
500 501 "status": {"given": <given_status>,
501 502 "was_changed": <bool status_was_actually_changed> },
502 503 },
503 504 error : null
504 505 """
506 _ = request.translate
507
505 508 pull_request = get_pull_request_or_error(pullrequestid)
506 509 if Optional.extract(repoid):
507 510 repo = get_repo_or_error(repoid)
508 511 else:
509 512 repo = pull_request.target_repo
510 513
514 db_repo_name = repo.repo_name
511 515 auth_user = apiuser
512 516 if not isinstance(userid, Optional):
513 517 is_repo_admin = HasRepoPermissionAnyApi('repository.admin')(
514 user=apiuser, repo_name=repo.repo_name)
518 user=apiuser, repo_name=db_repo_name)
515 519 if has_superadmin_permission(apiuser) or is_repo_admin:
516 520 apiuser = get_user_or_error(userid)
517 521 auth_user = apiuser.AuthUser()
518 522 else:
519 523 raise JSONRPCError('userid is not the same as your user')
520 524
521 525 if pull_request.is_closed():
522 526 raise JSONRPCError(
523 527 'pull request `%s` comment failed, pull request is closed' % (
524 528 pullrequestid,))
525 529
526 530 if not PullRequestModel().check_user_read(
527 531 pull_request, apiuser, api=True):
528 532 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
529 533 message = Optional.extract(message)
530 534 status = Optional.extract(status)
531 535 commit_id = Optional.extract(commit_id)
532 536 comment_type = Optional.extract(comment_type)
533 537 resolves_comment_id = Optional.extract(resolves_comment_id)
534 538 extra_recipients = Optional.extract(extra_recipients)
535 539 send_email = Optional.extract(send_email, binary=True)
536 540
537 541 if not message and not status:
538 542 raise JSONRPCError(
539 543 'Both message and status parameters are missing. '
540 544 'At least one is required.')
541 545
542 546 if (status not in (st[0] for st in ChangesetStatus.STATUSES) and
543 547 status is not None):
544 548 raise JSONRPCError('Unknown comment status: `%s`' % status)
545 549
546 550 if commit_id and commit_id not in pull_request.revisions:
547 551 raise JSONRPCError(
548 552 'Invalid commit_id `%s` for this pull request.' % commit_id)
549 553
550 554 allowed_to_change_status = PullRequestModel().check_user_change_status(
551 555 pull_request, apiuser)
552 556
553 557 # if commit_id is passed re-validated if user is allowed to change status
554 558 # based on latest commit_id from the PR
555 559 if commit_id:
556 560 commit_idx = pull_request.revisions.index(commit_id)
557 561 if commit_idx != 0:
558 562 allowed_to_change_status = False
559 563
560 564 if resolves_comment_id:
561 565 comment = ChangesetComment.get(resolves_comment_id)
562 566 if not comment:
563 567 raise JSONRPCError(
564 568 'Invalid resolves_comment_id `%s` for this pull request.'
565 569 % resolves_comment_id)
566 570 if comment.comment_type != ChangesetComment.COMMENT_TYPE_TODO:
567 571 raise JSONRPCError(
568 572 'Comment `%s` is wrong type for setting status to resolved.'
569 573 % resolves_comment_id)
570 574
571 575 text = message
572 576 status_label = ChangesetStatus.get_status_lbl(status)
573 577 if status and allowed_to_change_status:
574 578 st_message = ('Status change %(transition_icon)s %(status)s'
575 579 % {'transition_icon': '>', 'status': status_label})
576 580 text = message or st_message
577 581
578 582 rc_config = SettingsModel().get_all_settings()
579 583 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
580 584
581 585 status_change = status and allowed_to_change_status
582 586 comment = CommentsModel().create(
583 587 text=text,
584 588 repo=pull_request.target_repo.repo_id,
585 589 user=apiuser.user_id,
586 590 pull_request=pull_request.pull_request_id,
587 591 f_path=None,
588 592 line_no=None,
589 593 status_change=(status_label if status_change else None),
590 594 status_change_type=(status if status_change else None),
591 595 closing_pr=False,
592 596 renderer=renderer,
593 597 comment_type=comment_type,
594 598 resolves_comment_id=resolves_comment_id,
595 599 auth_user=auth_user,
596 600 extra_recipients=extra_recipients,
597 601 send_email=send_email
598 602 )
603 is_inline = bool(comment.f_path and comment.line_no)
599 604
600 605 if allowed_to_change_status and status:
601 606 old_calculated_status = pull_request.calculated_review_status()
602 607 ChangesetStatusModel().set_status(
603 608 pull_request.target_repo.repo_id,
604 609 status,
605 610 apiuser.user_id,
606 611 comment,
607 612 pull_request=pull_request.pull_request_id
608 613 )
609 614 Session().flush()
610 615
611 616 Session().commit()
612 617
613 618 PullRequestModel().trigger_pull_request_hook(
614 619 pull_request, apiuser, 'comment',
615 620 data={'comment': comment})
616 621
617 622 if allowed_to_change_status and status:
618 623 # we now calculate the status of pull request, and based on that
619 624 # calculation we set the commits status
620 625 calculated_status = pull_request.calculated_review_status()
621 626 if old_calculated_status != calculated_status:
622 627 PullRequestModel().trigger_pull_request_hook(
623 628 pull_request, apiuser, 'review_status_change',
624 629 data={'status': calculated_status})
625 630
626 631 data = {
627 632 'pull_request_id': pull_request.pull_request_id,
628 633 'comment_id': comment.comment_id if comment else None,
629 634 'status': {'given': status, 'was_changed': status_change},
630 635 }
636
637 comment_broadcast_channel = channelstream.comment_channel(
638 db_repo_name, pull_request_obj=pull_request)
639
640 comment_data = data
641 comment_type = 'inline' if is_inline else 'general'
642 channelstream.comment_channelstream_push(
643 request, comment_broadcast_channel, apiuser,
644 _('posted a new {} comment').format(comment_type),
645 comment_data=comment_data)
646
631 647 return data
632 648
633 649
634 650 @jsonrpc_method()
635 651 def create_pull_request(
636 652 request, apiuser, source_repo, target_repo, source_ref, target_ref,
637 653 owner=Optional(OAttr('apiuser')), title=Optional(''), description=Optional(''),
638 654 description_renderer=Optional(''), reviewers=Optional(None)):
639 655 """
640 656 Creates a new pull request.
641 657
642 658 Accepts refs in the following formats:
643 659
644 660 * branch:<branch_name>:<sha>
645 661 * branch:<branch_name>
646 662 * bookmark:<bookmark_name>:<sha> (Mercurial only)
647 663 * bookmark:<bookmark_name> (Mercurial only)
648 664
649 665 :param apiuser: This is filled automatically from the |authtoken|.
650 666 :type apiuser: AuthUser
651 667 :param source_repo: Set the source repository name.
652 668 :type source_repo: str
653 669 :param target_repo: Set the target repository name.
654 670 :type target_repo: str
655 671 :param source_ref: Set the source ref name.
656 672 :type source_ref: str
657 673 :param target_ref: Set the target ref name.
658 674 :type target_ref: str
659 675 :param owner: user_id or username
660 676 :type owner: Optional(str)
661 677 :param title: Optionally Set the pull request title, it's generated otherwise
662 678 :type title: str
663 679 :param description: Set the pull request description.
664 680 :type description: Optional(str)
665 681 :type description_renderer: Optional(str)
666 682 :param description_renderer: Set pull request renderer for the description.
667 683 It should be 'rst', 'markdown' or 'plain'. If not give default
668 684 system renderer will be used
669 685 :param reviewers: Set the new pull request reviewers list.
670 686 Reviewer defined by review rules will be added automatically to the
671 687 defined list.
672 688 :type reviewers: Optional(list)
673 689 Accepts username strings or objects of the format:
674 690
675 691 [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}]
676 692 """
677 693
678 694 source_db_repo = get_repo_or_error(source_repo)
679 695 target_db_repo = get_repo_or_error(target_repo)
680 696 if not has_superadmin_permission(apiuser):
681 697 _perms = ('repository.admin', 'repository.write', 'repository.read',)
682 698 validate_repo_permissions(apiuser, source_repo, source_db_repo, _perms)
683 699
684 700 owner = validate_set_owner_permissions(apiuser, owner)
685 701
686 702 full_source_ref = resolve_ref_or_error(source_ref, source_db_repo)
687 703 full_target_ref = resolve_ref_or_error(target_ref, target_db_repo)
688 704
689 705 source_commit = get_commit_or_error(full_source_ref, source_db_repo)
690 706 target_commit = get_commit_or_error(full_target_ref, target_db_repo)
691 707
692 708 reviewer_objects = Optional.extract(reviewers) or []
693 709
694 710 # serialize and validate passed in given reviewers
695 711 if reviewer_objects:
696 712 schema = ReviewerListSchema()
697 713 try:
698 714 reviewer_objects = schema.deserialize(reviewer_objects)
699 715 except Invalid as err:
700 716 raise JSONRPCValidationError(colander_exc=err)
701 717
702 718 # validate users
703 719 for reviewer_object in reviewer_objects:
704 720 user = get_user_or_error(reviewer_object['username'])
705 721 reviewer_object['user_id'] = user.user_id
706 722
707 723 get_default_reviewers_data, validate_default_reviewers, validate_observers = \
708 724 PullRequestModel().get_reviewer_functions()
709 725
710 726 # recalculate reviewers logic, to make sure we can validate this
711 727 default_reviewers_data = get_default_reviewers_data(
712 728 owner, source_db_repo,
713 729 source_commit, target_db_repo, target_commit)
714 730
715 731 # now MERGE our given with the calculated
716 732 reviewer_objects = default_reviewers_data['reviewers'] + reviewer_objects
717 733
718 734 try:
719 735 reviewers = validate_default_reviewers(
720 736 reviewer_objects, default_reviewers_data)
721 737 except ValueError as e:
722 738 raise JSONRPCError('Reviewers Validation: {}'.format(e))
723 739
724 740 title = Optional.extract(title)
725 741 if not title:
726 742 title_source_ref = source_ref.split(':', 2)[1]
727 743 title = PullRequestModel().generate_pullrequest_title(
728 744 source=source_repo,
729 745 source_ref=title_source_ref,
730 746 target=target_repo
731 747 )
732 748
733 749 diff_info = default_reviewers_data['diff_info']
734 750 common_ancestor_id = diff_info['ancestor']
735 751 commits = diff_info['commits']
736 752
737 753 if not common_ancestor_id:
738 754 raise JSONRPCError('no common ancestor found')
739 755
740 756 if not commits:
741 757 raise JSONRPCError('no commits found')
742 758
743 759 # NOTE(marcink): reversed is consistent with how we open it in the WEB interface
744 760 revisions = [commit.raw_id for commit in reversed(commits)]
745 761
746 762 # recalculate target ref based on ancestor
747 763 target_ref_type, target_ref_name, __ = full_target_ref.split(':')
748 764 full_target_ref = ':'.join((target_ref_type, target_ref_name, common_ancestor_id))
749 765
750 766 # fetch renderer, if set fallback to plain in case of PR
751 767 rc_config = SettingsModel().get_all_settings()
752 768 default_system_renderer = rc_config.get('rhodecode_markup_renderer', 'plain')
753 769 description = Optional.extract(description)
754 770 description_renderer = Optional.extract(description_renderer) or default_system_renderer
755 771
756 772 pull_request = PullRequestModel().create(
757 773 created_by=owner.user_id,
758 774 source_repo=source_repo,
759 775 source_ref=full_source_ref,
760 776 target_repo=target_repo,
761 777 target_ref=full_target_ref,
762 778 common_ancestor_id=common_ancestor_id,
763 779 revisions=revisions,
764 780 reviewers=reviewers,
765 781 title=title,
766 782 description=description,
767 783 description_renderer=description_renderer,
768 784 reviewer_data=default_reviewers_data,
769 785 auth_user=apiuser
770 786 )
771 787
772 788 Session().commit()
773 789 data = {
774 790 'msg': 'Created new pull request `{}`'.format(title),
775 791 'pull_request_id': pull_request.pull_request_id,
776 792 }
777 793 return data
778 794
779 795
780 796 @jsonrpc_method()
781 797 def update_pull_request(
782 798 request, apiuser, pullrequestid, repoid=Optional(None),
783 799 title=Optional(''), description=Optional(''), description_renderer=Optional(''),
784 800 reviewers=Optional(None), update_commits=Optional(None)):
785 801 """
786 802 Updates a pull request.
787 803
788 804 :param apiuser: This is filled automatically from the |authtoken|.
789 805 :type apiuser: AuthUser
790 806 :param repoid: Optional repository name or repository ID.
791 807 :type repoid: str or int
792 808 :param pullrequestid: The pull request ID.
793 809 :type pullrequestid: int
794 810 :param title: Set the pull request title.
795 811 :type title: str
796 812 :param description: Update pull request description.
797 813 :type description: Optional(str)
798 814 :type description_renderer: Optional(str)
799 815 :param description_renderer: Update pull request renderer for the description.
800 816 It should be 'rst', 'markdown' or 'plain'
801 817 :param reviewers: Update pull request reviewers list with new value.
802 818 :type reviewers: Optional(list)
803 819 Accepts username strings or objects of the format:
804 820
805 821 [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}]
806 822
807 823 :param update_commits: Trigger update of commits for this pull request
808 824 :type: update_commits: Optional(bool)
809 825
810 826 Example output:
811 827
812 828 .. code-block:: bash
813 829
814 830 id : <id_given_in_input>
815 831 result : {
816 832 "msg": "Updated pull request `63`",
817 833 "pull_request": <pull_request_object>,
818 834 "updated_reviewers": {
819 835 "added": [
820 836 "username"
821 837 ],
822 838 "removed": []
823 839 },
824 840 "updated_commits": {
825 841 "added": [
826 842 "<sha1_hash>"
827 843 ],
828 844 "common": [
829 845 "<sha1_hash>",
830 846 "<sha1_hash>",
831 847 ],
832 848 "removed": []
833 849 }
834 850 }
835 851 error : null
836 852 """
837 853
838 854 pull_request = get_pull_request_or_error(pullrequestid)
839 855 if Optional.extract(repoid):
840 856 repo = get_repo_or_error(repoid)
841 857 else:
842 858 repo = pull_request.target_repo
843 859
844 860 if not PullRequestModel().check_user_update(
845 861 pull_request, apiuser, api=True):
846 862 raise JSONRPCError(
847 863 'pull request `%s` update failed, no permission to update.' % (
848 864 pullrequestid,))
849 865 if pull_request.is_closed():
850 866 raise JSONRPCError(
851 867 'pull request `%s` update failed, pull request is closed' % (
852 868 pullrequestid,))
853 869
854 870 reviewer_objects = Optional.extract(reviewers) or []
855 871
856 872 if reviewer_objects:
857 873 schema = ReviewerListSchema()
858 874 try:
859 875 reviewer_objects = schema.deserialize(reviewer_objects)
860 876 except Invalid as err:
861 877 raise JSONRPCValidationError(colander_exc=err)
862 878
863 879 # validate users
864 880 for reviewer_object in reviewer_objects:
865 881 user = get_user_or_error(reviewer_object['username'])
866 882 reviewer_object['user_id'] = user.user_id
867 883
868 884 get_default_reviewers_data, get_validated_reviewers, validate_observers = \
869 885 PullRequestModel().get_reviewer_functions()
870 886
871 887 # re-use stored rules
872 888 reviewer_rules = pull_request.reviewer_data
873 889 try:
874 890 reviewers = get_validated_reviewers(reviewer_objects, reviewer_rules)
875 891 except ValueError as e:
876 892 raise JSONRPCError('Reviewers Validation: {}'.format(e))
877 893 else:
878 894 reviewers = []
879 895
880 896 title = Optional.extract(title)
881 897 description = Optional.extract(description)
882 898 description_renderer = Optional.extract(description_renderer)
883 899
900 # Update title/description
901 title_changed = False
884 902 if title or description:
885 903 PullRequestModel().edit(
886 904 pull_request,
887 905 title or pull_request.title,
888 906 description or pull_request.description,
889 907 description_renderer or pull_request.description_renderer,
890 908 apiuser)
891 909 Session().commit()
910 title_changed = True
892 911
893 912 commit_changes = {"added": [], "common": [], "removed": []}
913
914 # Update commits
915 commits_changed = False
894 916 if str2bool(Optional.extract(update_commits)):
895 917
896 918 if pull_request.pull_request_state != PullRequest.STATE_CREATED:
897 919 raise JSONRPCError(
898 920 'Operation forbidden because pull request is in state {}, '
899 921 'only state {} is allowed.'.format(
900 922 pull_request.pull_request_state, PullRequest.STATE_CREATED))
901 923
902 924 with pull_request.set_state(PullRequest.STATE_UPDATING):
903 925 if PullRequestModel().has_valid_update_type(pull_request):
904 926 db_user = apiuser.get_instance()
905 927 update_response = PullRequestModel().update_commits(
906 928 pull_request, db_user)
907 929 commit_changes = update_response.changes or commit_changes
908 930 Session().commit()
931 commits_changed = True
909 932
933 # Update reviewers
934 reviewers_changed = False
910 935 reviewers_changes = {"added": [], "removed": []}
911 936 if reviewers:
912 937 old_calculated_status = pull_request.calculated_review_status()
913 938 added_reviewers, removed_reviewers = \
914 939 PullRequestModel().update_reviewers(pull_request, reviewers, apiuser)
915 940
916 941 reviewers_changes['added'] = sorted(
917 942 [get_user_or_error(n).username for n in added_reviewers])
918 943 reviewers_changes['removed'] = sorted(
919 944 [get_user_or_error(n).username for n in removed_reviewers])
920 945 Session().commit()
921 946
922 947 # trigger status changed if change in reviewers changes the status
923 948 calculated_status = pull_request.calculated_review_status()
924 949 if old_calculated_status != calculated_status:
925 950 PullRequestModel().trigger_pull_request_hook(
926 951 pull_request, apiuser, 'review_status_change',
927 952 data={'status': calculated_status})
953 reviewers_changed = True
954
955 observers_changed = False
956
957 # push changed to channelstream
958 if commits_changed or reviewers_changed or observers_changed:
959 pr_broadcast_channel = channelstream.pr_channel(pull_request)
960 msg = 'Pull request was updated.'
961 channelstream.pr_update_channelstream_push(
962 request, pr_broadcast_channel, apiuser, msg)
928 963
929 964 data = {
930 965 'msg': 'Updated pull request `{}`'.format(
931 966 pull_request.pull_request_id),
932 967 'pull_request': pull_request.get_api_data(),
933 968 'updated_commits': commit_changes,
934 969 'updated_reviewers': reviewers_changes
935 970 }
936 971
937 972 return data
938 973
939 974
940 975 @jsonrpc_method()
941 976 def close_pull_request(
942 977 request, apiuser, pullrequestid, repoid=Optional(None),
943 978 userid=Optional(OAttr('apiuser')), message=Optional('')):
944 979 """
945 980 Close the pull request specified by `pullrequestid`.
946 981
947 982 :param apiuser: This is filled automatically from the |authtoken|.
948 983 :type apiuser: AuthUser
949 984 :param repoid: Repository name or repository ID to which the pull
950 985 request belongs.
951 986 :type repoid: str or int
952 987 :param pullrequestid: ID of the pull request to be closed.
953 988 :type pullrequestid: int
954 989 :param userid: Close the pull request as this user.
955 990 :type userid: Optional(str or int)
956 991 :param message: Optional message to close the Pull Request with. If not
957 992 specified it will be generated automatically.
958 993 :type message: Optional(str)
959 994
960 995 Example output:
961 996
962 997 .. code-block:: bash
963 998
964 999 "id": <id_given_in_input>,
965 1000 "result": {
966 1001 "pull_request_id": "<int>",
967 1002 "close_status": "<str:status_lbl>,
968 1003 "closed": "<bool>"
969 1004 },
970 1005 "error": null
971 1006
972 1007 """
973 1008 _ = request.translate
974 1009
975 1010 pull_request = get_pull_request_or_error(pullrequestid)
976 1011 if Optional.extract(repoid):
977 1012 repo = get_repo_or_error(repoid)
978 1013 else:
979 1014 repo = pull_request.target_repo
980 1015
981 1016 is_repo_admin = HasRepoPermissionAnyApi('repository.admin')(
982 1017 user=apiuser, repo_name=repo.repo_name)
983 1018 if not isinstance(userid, Optional):
984 1019 if has_superadmin_permission(apiuser) or is_repo_admin:
985 1020 apiuser = get_user_or_error(userid)
986 1021 else:
987 1022 raise JSONRPCError('userid is not the same as your user')
988 1023
989 1024 if pull_request.is_closed():
990 1025 raise JSONRPCError(
991 1026 'pull request `%s` is already closed' % (pullrequestid,))
992 1027
993 1028 # only owner or admin or person with write permissions
994 1029 allowed_to_close = PullRequestModel().check_user_update(
995 1030 pull_request, apiuser, api=True)
996 1031
997 1032 if not allowed_to_close:
998 1033 raise JSONRPCError(
999 1034 'pull request `%s` close failed, no permission to close.' % (
1000 1035 pullrequestid,))
1001 1036
1002 1037 # message we're using to close the PR, else it's automatically generated
1003 1038 message = Optional.extract(message)
1004 1039
1005 1040 # finally close the PR, with proper message comment
1006 1041 comment, status = PullRequestModel().close_pull_request_with_comment(
1007 1042 pull_request, apiuser, repo, message=message, auth_user=apiuser)
1008 1043 status_lbl = ChangesetStatus.get_status_lbl(status)
1009 1044
1010 1045 Session().commit()
1011 1046
1012 1047 data = {
1013 1048 'pull_request_id': pull_request.pull_request_id,
1014 1049 'close_status': status_lbl,
1015 1050 'closed': True,
1016 1051 }
1017 1052 return data
@@ -1,2507 +1,2523 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import time
23 23
24 24 import rhodecode
25 25 from rhodecode.api import (
26 26 jsonrpc_method, JSONRPCError, JSONRPCForbidden, JSONRPCValidationError)
27 27 from rhodecode.api.utils import (
28 28 has_superadmin_permission, Optional, OAttr, get_repo_or_error,
29 29 get_user_group_or_error, get_user_or_error, validate_repo_permissions,
30 30 get_perm_or_error, parse_args, get_origin, build_commit_data,
31 31 validate_set_owner_permissions)
32 from rhodecode.lib import audit_logger, rc_cache
32 from rhodecode.lib import audit_logger, rc_cache, channelstream
33 33 from rhodecode.lib import repo_maintenance
34 34 from rhodecode.lib.auth import (
35 35 HasPermissionAnyApi, HasUserGroupPermissionAnyApi,
36 36 HasRepoPermissionAnyApi)
37 37 from rhodecode.lib.celerylib.utils import get_task_id
38 38 from rhodecode.lib.utils2 import (
39 39 str2bool, time_to_datetime, safe_str, safe_int, safe_unicode)
40 40 from rhodecode.lib.ext_json import json
41 41 from rhodecode.lib.exceptions import (
42 42 StatusChangeOnClosedPullRequestError, CommentVersionMismatch)
43 43 from rhodecode.lib.vcs import RepositoryError
44 44 from rhodecode.lib.vcs.exceptions import NodeDoesNotExistError
45 45 from rhodecode.model.changeset_status import ChangesetStatusModel
46 46 from rhodecode.model.comment import CommentsModel
47 47 from rhodecode.model.db import (
48 48 Session, ChangesetStatus, RepositoryField, Repository, RepoGroup,
49 49 ChangesetComment)
50 50 from rhodecode.model.permission import PermissionModel
51 51 from rhodecode.model.pull_request import PullRequestModel
52 52 from rhodecode.model.repo import RepoModel
53 53 from rhodecode.model.scm import ScmModel, RepoList
54 54 from rhodecode.model.settings import SettingsModel, VcsSettingsModel
55 55 from rhodecode.model import validation_schema
56 56 from rhodecode.model.validation_schema.schemas import repo_schema
57 57
58 58 log = logging.getLogger(__name__)
59 59
60 60
61 61 @jsonrpc_method()
62 62 def get_repo(request, apiuser, repoid, cache=Optional(True)):
63 63 """
64 64 Gets an existing repository by its name or repository_id.
65 65
66 66 The members section so the output returns users groups or users
67 67 associated with that repository.
68 68
69 69 This command can only be run using an |authtoken| with admin rights,
70 70 or users with at least read rights to the |repo|.
71 71
72 72 :param apiuser: This is filled automatically from the |authtoken|.
73 73 :type apiuser: AuthUser
74 74 :param repoid: The repository name or repository id.
75 75 :type repoid: str or int
76 76 :param cache: use the cached value for last changeset
77 77 :type: cache: Optional(bool)
78 78
79 79 Example output:
80 80
81 81 .. code-block:: bash
82 82
83 83 {
84 84 "error": null,
85 85 "id": <repo_id>,
86 86 "result": {
87 87 "clone_uri": null,
88 88 "created_on": "timestamp",
89 89 "description": "repo description",
90 90 "enable_downloads": false,
91 91 "enable_locking": false,
92 92 "enable_statistics": false,
93 93 "followers": [
94 94 {
95 95 "active": true,
96 96 "admin": false,
97 97 "api_key": "****************************************",
98 98 "api_keys": [
99 99 "****************************************"
100 100 ],
101 101 "email": "user@example.com",
102 102 "emails": [
103 103 "user@example.com"
104 104 ],
105 105 "extern_name": "rhodecode",
106 106 "extern_type": "rhodecode",
107 107 "firstname": "username",
108 108 "ip_addresses": [],
109 109 "language": null,
110 110 "last_login": "2015-09-16T17:16:35.854",
111 111 "lastname": "surname",
112 112 "user_id": <user_id>,
113 113 "username": "name"
114 114 }
115 115 ],
116 116 "fork_of": "parent-repo",
117 117 "landing_rev": [
118 118 "rev",
119 119 "tip"
120 120 ],
121 121 "last_changeset": {
122 122 "author": "User <user@example.com>",
123 123 "branch": "default",
124 124 "date": "timestamp",
125 125 "message": "last commit message",
126 126 "parents": [
127 127 {
128 128 "raw_id": "commit-id"
129 129 }
130 130 ],
131 131 "raw_id": "commit-id",
132 132 "revision": <revision number>,
133 133 "short_id": "short id"
134 134 },
135 135 "lock_reason": null,
136 136 "locked_by": null,
137 137 "locked_date": null,
138 138 "owner": "owner-name",
139 139 "permissions": [
140 140 {
141 141 "name": "super-admin-name",
142 142 "origin": "super-admin",
143 143 "permission": "repository.admin",
144 144 "type": "user"
145 145 },
146 146 {
147 147 "name": "owner-name",
148 148 "origin": "owner",
149 149 "permission": "repository.admin",
150 150 "type": "user"
151 151 },
152 152 {
153 153 "name": "user-group-name",
154 154 "origin": "permission",
155 155 "permission": "repository.write",
156 156 "type": "user_group"
157 157 }
158 158 ],
159 159 "private": true,
160 160 "repo_id": 676,
161 161 "repo_name": "user-group/repo-name",
162 162 "repo_type": "hg"
163 163 }
164 164 }
165 165 """
166 166
167 167 repo = get_repo_or_error(repoid)
168 168 cache = Optional.extract(cache)
169 169
170 170 include_secrets = False
171 171 if has_superadmin_permission(apiuser):
172 172 include_secrets = True
173 173 else:
174 174 # check if we have at least read permission for this repo !
175 175 _perms = (
176 176 'repository.admin', 'repository.write', 'repository.read',)
177 177 validate_repo_permissions(apiuser, repoid, repo, _perms)
178 178
179 179 permissions = []
180 180 for _user in repo.permissions():
181 181 user_data = {
182 182 'name': _user.username,
183 183 'permission': _user.permission,
184 184 'origin': get_origin(_user),
185 185 'type': "user",
186 186 }
187 187 permissions.append(user_data)
188 188
189 189 for _user_group in repo.permission_user_groups():
190 190 user_group_data = {
191 191 'name': _user_group.users_group_name,
192 192 'permission': _user_group.permission,
193 193 'origin': get_origin(_user_group),
194 194 'type': "user_group",
195 195 }
196 196 permissions.append(user_group_data)
197 197
198 198 following_users = [
199 199 user.user.get_api_data(include_secrets=include_secrets)
200 200 for user in repo.followers]
201 201
202 202 if not cache:
203 203 repo.update_commit_cache()
204 204 data = repo.get_api_data(include_secrets=include_secrets)
205 205 data['permissions'] = permissions
206 206 data['followers'] = following_users
207 207 return data
208 208
209 209
210 210 @jsonrpc_method()
211 211 def get_repos(request, apiuser, root=Optional(None), traverse=Optional(True)):
212 212 """
213 213 Lists all existing repositories.
214 214
215 215 This command can only be run using an |authtoken| with admin rights,
216 216 or users with at least read rights to |repos|.
217 217
218 218 :param apiuser: This is filled automatically from the |authtoken|.
219 219 :type apiuser: AuthUser
220 220 :param root: specify root repository group to fetch repositories.
221 221 filters the returned repositories to be members of given root group.
222 222 :type root: Optional(None)
223 223 :param traverse: traverse given root into subrepositories. With this flag
224 224 set to False, it will only return top-level repositories from `root`.
225 225 if root is empty it will return just top-level repositories.
226 226 :type traverse: Optional(True)
227 227
228 228
229 229 Example output:
230 230
231 231 .. code-block:: bash
232 232
233 233 id : <id_given_in_input>
234 234 result: [
235 235 {
236 236 "repo_id" : "<repo_id>",
237 237 "repo_name" : "<reponame>"
238 238 "repo_type" : "<repo_type>",
239 239 "clone_uri" : "<clone_uri>",
240 240 "private": : "<bool>",
241 241 "created_on" : "<datetimecreated>",
242 242 "description" : "<description>",
243 243 "landing_rev": "<landing_rev>",
244 244 "owner": "<repo_owner>",
245 245 "fork_of": "<name_of_fork_parent>",
246 246 "enable_downloads": "<bool>",
247 247 "enable_locking": "<bool>",
248 248 "enable_statistics": "<bool>",
249 249 },
250 250 ...
251 251 ]
252 252 error: null
253 253 """
254 254
255 255 include_secrets = has_superadmin_permission(apiuser)
256 256 _perms = ('repository.read', 'repository.write', 'repository.admin',)
257 257 extras = {'user': apiuser}
258 258
259 259 root = Optional.extract(root)
260 260 traverse = Optional.extract(traverse, binary=True)
261 261
262 262 if root:
263 263 # verify parent existance, if it's empty return an error
264 264 parent = RepoGroup.get_by_group_name(root)
265 265 if not parent:
266 266 raise JSONRPCError(
267 267 'Root repository group `{}` does not exist'.format(root))
268 268
269 269 if traverse:
270 270 repos = RepoModel().get_repos_for_root(root=root, traverse=traverse)
271 271 else:
272 272 repos = RepoModel().get_repos_for_root(root=parent)
273 273 else:
274 274 if traverse:
275 275 repos = RepoModel().get_all()
276 276 else:
277 277 # return just top-level
278 278 repos = RepoModel().get_repos_for_root(root=None)
279 279
280 280 repo_list = RepoList(repos, perm_set=_perms, extra_kwargs=extras)
281 281 return [repo.get_api_data(include_secrets=include_secrets)
282 282 for repo in repo_list]
283 283
284 284
285 285 @jsonrpc_method()
286 286 def get_repo_changeset(request, apiuser, repoid, revision,
287 287 details=Optional('basic')):
288 288 """
289 289 Returns information about a changeset.
290 290
291 291 Additionally parameters define the amount of details returned by
292 292 this function.
293 293
294 294 This command can only be run using an |authtoken| with admin rights,
295 295 or users with at least read rights to the |repo|.
296 296
297 297 :param apiuser: This is filled automatically from the |authtoken|.
298 298 :type apiuser: AuthUser
299 299 :param repoid: The repository name or repository id
300 300 :type repoid: str or int
301 301 :param revision: revision for which listing should be done
302 302 :type revision: str
303 303 :param details: details can be 'basic|extended|full' full gives diff
304 304 info details like the diff itself, and number of changed files etc.
305 305 :type details: Optional(str)
306 306
307 307 """
308 308 repo = get_repo_or_error(repoid)
309 309 if not has_superadmin_permission(apiuser):
310 310 _perms = ('repository.admin', 'repository.write', 'repository.read',)
311 311 validate_repo_permissions(apiuser, repoid, repo, _perms)
312 312
313 313 changes_details = Optional.extract(details)
314 314 _changes_details_types = ['basic', 'extended', 'full']
315 315 if changes_details not in _changes_details_types:
316 316 raise JSONRPCError(
317 317 'ret_type must be one of %s' % (
318 318 ','.join(_changes_details_types)))
319 319
320 320 pre_load = ['author', 'branch', 'date', 'message', 'parents',
321 321 'status', '_commit', '_file_paths']
322 322
323 323 try:
324 324 cs = repo.get_commit(commit_id=revision, pre_load=pre_load)
325 325 except TypeError as e:
326 326 raise JSONRPCError(safe_str(e))
327 327 _cs_json = cs.__json__()
328 328 _cs_json['diff'] = build_commit_data(cs, changes_details)
329 329 if changes_details == 'full':
330 330 _cs_json['refs'] = cs._get_refs()
331 331 return _cs_json
332 332
333 333
334 334 @jsonrpc_method()
335 335 def get_repo_changesets(request, apiuser, repoid, start_rev, limit,
336 336 details=Optional('basic')):
337 337 """
338 338 Returns a set of commits limited by the number starting
339 339 from the `start_rev` option.
340 340
341 341 Additional parameters define the amount of details returned by this
342 342 function.
343 343
344 344 This command can only be run using an |authtoken| with admin rights,
345 345 or users with at least read rights to |repos|.
346 346
347 347 :param apiuser: This is filled automatically from the |authtoken|.
348 348 :type apiuser: AuthUser
349 349 :param repoid: The repository name or repository ID.
350 350 :type repoid: str or int
351 351 :param start_rev: The starting revision from where to get changesets.
352 352 :type start_rev: str
353 353 :param limit: Limit the number of commits to this amount
354 354 :type limit: str or int
355 355 :param details: Set the level of detail returned. Valid option are:
356 356 ``basic``, ``extended`` and ``full``.
357 357 :type details: Optional(str)
358 358
359 359 .. note::
360 360
361 361 Setting the parameter `details` to the value ``full`` is extensive
362 362 and returns details like the diff itself, and the number
363 363 of changed files.
364 364
365 365 """
366 366 repo = get_repo_or_error(repoid)
367 367 if not has_superadmin_permission(apiuser):
368 368 _perms = ('repository.admin', 'repository.write', 'repository.read',)
369 369 validate_repo_permissions(apiuser, repoid, repo, _perms)
370 370
371 371 changes_details = Optional.extract(details)
372 372 _changes_details_types = ['basic', 'extended', 'full']
373 373 if changes_details not in _changes_details_types:
374 374 raise JSONRPCError(
375 375 'ret_type must be one of %s' % (
376 376 ','.join(_changes_details_types)))
377 377
378 378 limit = int(limit)
379 379 pre_load = ['author', 'branch', 'date', 'message', 'parents',
380 380 'status', '_commit', '_file_paths']
381 381
382 382 vcs_repo = repo.scm_instance()
383 383 # SVN needs a special case to distinguish its index and commit id
384 384 if vcs_repo and vcs_repo.alias == 'svn' and (start_rev == '0'):
385 385 start_rev = vcs_repo.commit_ids[0]
386 386
387 387 try:
388 388 commits = vcs_repo.get_commits(
389 389 start_id=start_rev, pre_load=pre_load, translate_tags=False)
390 390 except TypeError as e:
391 391 raise JSONRPCError(safe_str(e))
392 392 except Exception:
393 393 log.exception('Fetching of commits failed')
394 394 raise JSONRPCError('Error occurred during commit fetching')
395 395
396 396 ret = []
397 397 for cnt, commit in enumerate(commits):
398 398 if cnt >= limit != -1:
399 399 break
400 400 _cs_json = commit.__json__()
401 401 _cs_json['diff'] = build_commit_data(commit, changes_details)
402 402 if changes_details == 'full':
403 403 _cs_json['refs'] = {
404 404 'branches': [commit.branch],
405 405 'bookmarks': getattr(commit, 'bookmarks', []),
406 406 'tags': commit.tags
407 407 }
408 408 ret.append(_cs_json)
409 409 return ret
410 410
411 411
412 412 @jsonrpc_method()
413 413 def get_repo_nodes(request, apiuser, repoid, revision, root_path,
414 414 ret_type=Optional('all'), details=Optional('basic'),
415 415 max_file_bytes=Optional(None)):
416 416 """
417 417 Returns a list of nodes and children in a flat list for a given
418 418 path at given revision.
419 419
420 420 It's possible to specify ret_type to show only `files` or `dirs`.
421 421
422 422 This command can only be run using an |authtoken| with admin rights,
423 423 or users with at least read rights to |repos|.
424 424
425 425 :param apiuser: This is filled automatically from the |authtoken|.
426 426 :type apiuser: AuthUser
427 427 :param repoid: The repository name or repository ID.
428 428 :type repoid: str or int
429 429 :param revision: The revision for which listing should be done.
430 430 :type revision: str
431 431 :param root_path: The path from which to start displaying.
432 432 :type root_path: str
433 433 :param ret_type: Set the return type. Valid options are
434 434 ``all`` (default), ``files`` and ``dirs``.
435 435 :type ret_type: Optional(str)
436 436 :param details: Returns extended information about nodes, such as
437 437 md5, binary, and or content.
438 438 The valid options are ``basic`` and ``full``.
439 439 :type details: Optional(str)
440 440 :param max_file_bytes: Only return file content under this file size bytes
441 441 :type details: Optional(int)
442 442
443 443 Example output:
444 444
445 445 .. code-block:: bash
446 446
447 447 id : <id_given_in_input>
448 448 result: [
449 449 {
450 450 "binary": false,
451 451 "content": "File line",
452 452 "extension": "md",
453 453 "lines": 2,
454 454 "md5": "059fa5d29b19c0657e384749480f6422",
455 455 "mimetype": "text/x-minidsrc",
456 456 "name": "file.md",
457 457 "size": 580,
458 458 "type": "file"
459 459 },
460 460 ...
461 461 ]
462 462 error: null
463 463 """
464 464
465 465 repo = get_repo_or_error(repoid)
466 466 if not has_superadmin_permission(apiuser):
467 467 _perms = ('repository.admin', 'repository.write', 'repository.read',)
468 468 validate_repo_permissions(apiuser, repoid, repo, _perms)
469 469
470 470 ret_type = Optional.extract(ret_type)
471 471 details = Optional.extract(details)
472 472 _extended_types = ['basic', 'full']
473 473 if details not in _extended_types:
474 474 raise JSONRPCError('ret_type must be one of %s' % (','.join(_extended_types)))
475 475 extended_info = False
476 476 content = False
477 477 if details == 'basic':
478 478 extended_info = True
479 479
480 480 if details == 'full':
481 481 extended_info = content = True
482 482
483 483 _map = {}
484 484 try:
485 485 # check if repo is not empty by any chance, skip quicker if it is.
486 486 _scm = repo.scm_instance()
487 487 if _scm.is_empty():
488 488 return []
489 489
490 490 _d, _f = ScmModel().get_nodes(
491 491 repo, revision, root_path, flat=False,
492 492 extended_info=extended_info, content=content,
493 493 max_file_bytes=max_file_bytes)
494 494 _map = {
495 495 'all': _d + _f,
496 496 'files': _f,
497 497 'dirs': _d,
498 498 }
499 499 return _map[ret_type]
500 500 except KeyError:
501 501 raise JSONRPCError(
502 502 'ret_type must be one of %s' % (','.join(sorted(_map.keys()))))
503 503 except Exception:
504 504 log.exception("Exception occurred while trying to get repo nodes")
505 505 raise JSONRPCError(
506 506 'failed to get repo: `%s` nodes' % repo.repo_name
507 507 )
508 508
509 509
510 510 @jsonrpc_method()
511 511 def get_repo_file(request, apiuser, repoid, commit_id, file_path,
512 512 max_file_bytes=Optional(None), details=Optional('basic'),
513 513 cache=Optional(True)):
514 514 """
515 515 Returns a single file from repository at given revision.
516 516
517 517 This command can only be run using an |authtoken| with admin rights,
518 518 or users with at least read rights to |repos|.
519 519
520 520 :param apiuser: This is filled automatically from the |authtoken|.
521 521 :type apiuser: AuthUser
522 522 :param repoid: The repository name or repository ID.
523 523 :type repoid: str or int
524 524 :param commit_id: The revision for which listing should be done.
525 525 :type commit_id: str
526 526 :param file_path: The path from which to start displaying.
527 527 :type file_path: str
528 528 :param details: Returns different set of information about nodes.
529 529 The valid options are ``minimal`` ``basic`` and ``full``.
530 530 :type details: Optional(str)
531 531 :param max_file_bytes: Only return file content under this file size bytes
532 532 :type max_file_bytes: Optional(int)
533 533 :param cache: Use internal caches for fetching files. If disabled fetching
534 534 files is slower but more memory efficient
535 535 :type cache: Optional(bool)
536 536
537 537 Example output:
538 538
539 539 .. code-block:: bash
540 540
541 541 id : <id_given_in_input>
542 542 result: {
543 543 "binary": false,
544 544 "extension": "py",
545 545 "lines": 35,
546 546 "content": "....",
547 547 "md5": "76318336366b0f17ee249e11b0c99c41",
548 548 "mimetype": "text/x-python",
549 549 "name": "python.py",
550 550 "size": 817,
551 551 "type": "file",
552 552 }
553 553 error: null
554 554 """
555 555
556 556 repo = get_repo_or_error(repoid)
557 557 if not has_superadmin_permission(apiuser):
558 558 _perms = ('repository.admin', 'repository.write', 'repository.read',)
559 559 validate_repo_permissions(apiuser, repoid, repo, _perms)
560 560
561 561 cache = Optional.extract(cache, binary=True)
562 562 details = Optional.extract(details)
563 563 _extended_types = ['minimal', 'minimal+search', 'basic', 'full']
564 564 if details not in _extended_types:
565 565 raise JSONRPCError(
566 566 'ret_type must be one of %s, got %s' % (','.join(_extended_types)), details)
567 567 extended_info = False
568 568 content = False
569 569
570 570 if details == 'minimal':
571 571 extended_info = False
572 572
573 573 elif details == 'basic':
574 574 extended_info = True
575 575
576 576 elif details == 'full':
577 577 extended_info = content = True
578 578
579 579 file_path = safe_unicode(file_path)
580 580 try:
581 581 # check if repo is not empty by any chance, skip quicker if it is.
582 582 _scm = repo.scm_instance()
583 583 if _scm.is_empty():
584 584 return None
585 585
586 586 node = ScmModel().get_node(
587 587 repo, commit_id, file_path, extended_info=extended_info,
588 588 content=content, max_file_bytes=max_file_bytes, cache=cache)
589 589 except NodeDoesNotExistError:
590 590 raise JSONRPCError(u'There is no file in repo: `{}` at path `{}` for commit: `{}`'.format(
591 591 repo.repo_name, file_path, commit_id))
592 592 except Exception:
593 593 log.exception(u"Exception occurred while trying to get repo %s file",
594 594 repo.repo_name)
595 595 raise JSONRPCError(u'failed to get repo: `{}` file at path {}'.format(
596 596 repo.repo_name, file_path))
597 597
598 598 return node
599 599
600 600
601 601 @jsonrpc_method()
602 602 def get_repo_fts_tree(request, apiuser, repoid, commit_id, root_path):
603 603 """
604 604 Returns a list of tree nodes for path at given revision. This api is built
605 605 strictly for usage in full text search building, and shouldn't be consumed
606 606
607 607 This command can only be run using an |authtoken| with admin rights,
608 608 or users with at least read rights to |repos|.
609 609
610 610 """
611 611
612 612 repo = get_repo_or_error(repoid)
613 613 if not has_superadmin_permission(apiuser):
614 614 _perms = ('repository.admin', 'repository.write', 'repository.read',)
615 615 validate_repo_permissions(apiuser, repoid, repo, _perms)
616 616
617 617 repo_id = repo.repo_id
618 618 cache_seconds = safe_int(rhodecode.CONFIG.get('rc_cache.cache_repo.expiration_time'))
619 619 cache_on = cache_seconds > 0
620 620
621 621 cache_namespace_uid = 'cache_repo.{}'.format(repo_id)
622 622 region = rc_cache.get_or_create_region('cache_repo', cache_namespace_uid)
623 623
624 624 def compute_fts_tree(cache_ver, repo_id, commit_id, root_path):
625 625 return ScmModel().get_fts_data(repo_id, commit_id, root_path)
626 626
627 627 try:
628 628 # check if repo is not empty by any chance, skip quicker if it is.
629 629 _scm = repo.scm_instance()
630 630 if _scm.is_empty():
631 631 return []
632 632 except RepositoryError:
633 633 log.exception("Exception occurred while trying to get repo nodes")
634 634 raise JSONRPCError('failed to get repo: `%s` nodes' % repo.repo_name)
635 635
636 636 try:
637 637 # we need to resolve commit_id to a FULL sha for cache to work correctly.
638 638 # sending 'master' is a pointer that needs to be translated to current commit.
639 639 commit_id = _scm.get_commit(commit_id=commit_id).raw_id
640 640 log.debug(
641 641 'Computing FTS REPO TREE for repo_id %s commit_id `%s` '
642 642 'with caching: %s[TTL: %ss]' % (
643 643 repo_id, commit_id, cache_on, cache_seconds or 0))
644 644
645 645 tree_files = compute_fts_tree(rc_cache.FILE_TREE_CACHE_VER, repo_id, commit_id, root_path)
646 646 return tree_files
647 647
648 648 except Exception:
649 649 log.exception("Exception occurred while trying to get repo nodes")
650 650 raise JSONRPCError('failed to get repo: `%s` nodes' % repo.repo_name)
651 651
652 652
653 653 @jsonrpc_method()
654 654 def get_repo_refs(request, apiuser, repoid):
655 655 """
656 656 Returns a dictionary of current references. It returns
657 657 bookmarks, branches, closed_branches, and tags for given repository
658 658
659 659 It's possible to specify ret_type to show only `files` or `dirs`.
660 660
661 661 This command can only be run using an |authtoken| with admin rights,
662 662 or users with at least read rights to |repos|.
663 663
664 664 :param apiuser: This is filled automatically from the |authtoken|.
665 665 :type apiuser: AuthUser
666 666 :param repoid: The repository name or repository ID.
667 667 :type repoid: str or int
668 668
669 669 Example output:
670 670
671 671 .. code-block:: bash
672 672
673 673 id : <id_given_in_input>
674 674 "result": {
675 675 "bookmarks": {
676 676 "dev": "5611d30200f4040ba2ab4f3d64e5b06408a02188",
677 677 "master": "367f590445081d8ec8c2ea0456e73ae1f1c3d6cf"
678 678 },
679 679 "branches": {
680 680 "default": "5611d30200f4040ba2ab4f3d64e5b06408a02188",
681 681 "stable": "367f590445081d8ec8c2ea0456e73ae1f1c3d6cf"
682 682 },
683 683 "branches_closed": {},
684 684 "tags": {
685 685 "tip": "5611d30200f4040ba2ab4f3d64e5b06408a02188",
686 686 "v4.4.0": "1232313f9e6adac5ce5399c2a891dc1e72b79022",
687 687 "v4.4.1": "cbb9f1d329ae5768379cdec55a62ebdd546c4e27",
688 688 "v4.4.2": "24ffe44a27fcd1c5b6936144e176b9f6dd2f3a17",
689 689 }
690 690 }
691 691 error: null
692 692 """
693 693
694 694 repo = get_repo_or_error(repoid)
695 695 if not has_superadmin_permission(apiuser):
696 696 _perms = ('repository.admin', 'repository.write', 'repository.read',)
697 697 validate_repo_permissions(apiuser, repoid, repo, _perms)
698 698
699 699 try:
700 700 # check if repo is not empty by any chance, skip quicker if it is.
701 701 vcs_instance = repo.scm_instance()
702 702 refs = vcs_instance.refs()
703 703 return refs
704 704 except Exception:
705 705 log.exception("Exception occurred while trying to get repo refs")
706 706 raise JSONRPCError(
707 707 'failed to get repo: `%s` references' % repo.repo_name
708 708 )
709 709
710 710
711 711 @jsonrpc_method()
712 712 def create_repo(
713 713 request, apiuser, repo_name, repo_type,
714 714 owner=Optional(OAttr('apiuser')),
715 715 description=Optional(''),
716 716 private=Optional(False),
717 717 clone_uri=Optional(None),
718 718 push_uri=Optional(None),
719 719 landing_rev=Optional(None),
720 720 enable_statistics=Optional(False),
721 721 enable_locking=Optional(False),
722 722 enable_downloads=Optional(False),
723 723 copy_permissions=Optional(False)):
724 724 """
725 725 Creates a repository.
726 726
727 727 * If the repository name contains "/", repository will be created inside
728 728 a repository group or nested repository groups
729 729
730 730 For example "foo/bar/repo1" will create |repo| called "repo1" inside
731 731 group "foo/bar". You have to have permissions to access and write to
732 732 the last repository group ("bar" in this example)
733 733
734 734 This command can only be run using an |authtoken| with at least
735 735 permissions to create repositories, or write permissions to
736 736 parent repository groups.
737 737
738 738 :param apiuser: This is filled automatically from the |authtoken|.
739 739 :type apiuser: AuthUser
740 740 :param repo_name: Set the repository name.
741 741 :type repo_name: str
742 742 :param repo_type: Set the repository type; 'hg','git', or 'svn'.
743 743 :type repo_type: str
744 744 :param owner: user_id or username
745 745 :type owner: Optional(str)
746 746 :param description: Set the repository description.
747 747 :type description: Optional(str)
748 748 :param private: set repository as private
749 749 :type private: bool
750 750 :param clone_uri: set clone_uri
751 751 :type clone_uri: str
752 752 :param push_uri: set push_uri
753 753 :type push_uri: str
754 754 :param landing_rev: <rev_type>:<rev>, e.g branch:default, book:dev, rev:abcd
755 755 :type landing_rev: str
756 756 :param enable_locking:
757 757 :type enable_locking: bool
758 758 :param enable_downloads:
759 759 :type enable_downloads: bool
760 760 :param enable_statistics:
761 761 :type enable_statistics: bool
762 762 :param copy_permissions: Copy permission from group in which the
763 763 repository is being created.
764 764 :type copy_permissions: bool
765 765
766 766
767 767 Example output:
768 768
769 769 .. code-block:: bash
770 770
771 771 id : <id_given_in_input>
772 772 result: {
773 773 "msg": "Created new repository `<reponame>`",
774 774 "success": true,
775 775 "task": "<celery task id or None if done sync>"
776 776 }
777 777 error: null
778 778
779 779
780 780 Example error output:
781 781
782 782 .. code-block:: bash
783 783
784 784 id : <id_given_in_input>
785 785 result : null
786 786 error : {
787 787 'failed to create repository `<repo_name>`'
788 788 }
789 789
790 790 """
791 791
792 792 owner = validate_set_owner_permissions(apiuser, owner)
793 793
794 794 description = Optional.extract(description)
795 795 copy_permissions = Optional.extract(copy_permissions)
796 796 clone_uri = Optional.extract(clone_uri)
797 797 push_uri = Optional.extract(push_uri)
798 798
799 799 defs = SettingsModel().get_default_repo_settings(strip_prefix=True)
800 800 if isinstance(private, Optional):
801 801 private = defs.get('repo_private') or Optional.extract(private)
802 802 if isinstance(repo_type, Optional):
803 803 repo_type = defs.get('repo_type')
804 804 if isinstance(enable_statistics, Optional):
805 805 enable_statistics = defs.get('repo_enable_statistics')
806 806 if isinstance(enable_locking, Optional):
807 807 enable_locking = defs.get('repo_enable_locking')
808 808 if isinstance(enable_downloads, Optional):
809 809 enable_downloads = defs.get('repo_enable_downloads')
810 810
811 811 landing_ref, _label = ScmModel.backend_landing_ref(repo_type)
812 812 ref_choices, _labels = ScmModel().get_repo_landing_revs(request.translate)
813 813 ref_choices = list(set(ref_choices + [landing_ref]))
814 814
815 815 landing_commit_ref = Optional.extract(landing_rev) or landing_ref
816 816
817 817 schema = repo_schema.RepoSchema().bind(
818 818 repo_type_options=rhodecode.BACKENDS.keys(),
819 819 repo_ref_options=ref_choices,
820 820 repo_type=repo_type,
821 821 # user caller
822 822 user=apiuser)
823 823
824 824 try:
825 825 schema_data = schema.deserialize(dict(
826 826 repo_name=repo_name,
827 827 repo_type=repo_type,
828 828 repo_owner=owner.username,
829 829 repo_description=description,
830 830 repo_landing_commit_ref=landing_commit_ref,
831 831 repo_clone_uri=clone_uri,
832 832 repo_push_uri=push_uri,
833 833 repo_private=private,
834 834 repo_copy_permissions=copy_permissions,
835 835 repo_enable_statistics=enable_statistics,
836 836 repo_enable_downloads=enable_downloads,
837 837 repo_enable_locking=enable_locking))
838 838 except validation_schema.Invalid as err:
839 839 raise JSONRPCValidationError(colander_exc=err)
840 840
841 841 try:
842 842 data = {
843 843 'owner': owner,
844 844 'repo_name': schema_data['repo_group']['repo_name_without_group'],
845 845 'repo_name_full': schema_data['repo_name'],
846 846 'repo_group': schema_data['repo_group']['repo_group_id'],
847 847 'repo_type': schema_data['repo_type'],
848 848 'repo_description': schema_data['repo_description'],
849 849 'repo_private': schema_data['repo_private'],
850 850 'clone_uri': schema_data['repo_clone_uri'],
851 851 'push_uri': schema_data['repo_push_uri'],
852 852 'repo_landing_rev': schema_data['repo_landing_commit_ref'],
853 853 'enable_statistics': schema_data['repo_enable_statistics'],
854 854 'enable_locking': schema_data['repo_enable_locking'],
855 855 'enable_downloads': schema_data['repo_enable_downloads'],
856 856 'repo_copy_permissions': schema_data['repo_copy_permissions'],
857 857 }
858 858
859 859 task = RepoModel().create(form_data=data, cur_user=owner.user_id)
860 860 task_id = get_task_id(task)
861 861 # no commit, it's done in RepoModel, or async via celery
862 862 return {
863 863 'msg': "Created new repository `%s`" % (schema_data['repo_name'],),
864 864 'success': True, # cannot return the repo data here since fork
865 865 # can be done async
866 866 'task': task_id
867 867 }
868 868 except Exception:
869 869 log.exception(
870 870 u"Exception while trying to create the repository %s",
871 871 schema_data['repo_name'])
872 872 raise JSONRPCError(
873 873 'failed to create repository `%s`' % (schema_data['repo_name'],))
874 874
875 875
876 876 @jsonrpc_method()
877 877 def add_field_to_repo(request, apiuser, repoid, key, label=Optional(''),
878 878 description=Optional('')):
879 879 """
880 880 Adds an extra field to a repository.
881 881
882 882 This command can only be run using an |authtoken| with at least
883 883 write permissions to the |repo|.
884 884
885 885 :param apiuser: This is filled automatically from the |authtoken|.
886 886 :type apiuser: AuthUser
887 887 :param repoid: Set the repository name or repository id.
888 888 :type repoid: str or int
889 889 :param key: Create a unique field key for this repository.
890 890 :type key: str
891 891 :param label:
892 892 :type label: Optional(str)
893 893 :param description:
894 894 :type description: Optional(str)
895 895 """
896 896 repo = get_repo_or_error(repoid)
897 897 if not has_superadmin_permission(apiuser):
898 898 _perms = ('repository.admin',)
899 899 validate_repo_permissions(apiuser, repoid, repo, _perms)
900 900
901 901 label = Optional.extract(label) or key
902 902 description = Optional.extract(description)
903 903
904 904 field = RepositoryField.get_by_key_name(key, repo)
905 905 if field:
906 906 raise JSONRPCError('Field with key '
907 907 '`%s` exists for repo `%s`' % (key, repoid))
908 908
909 909 try:
910 910 RepoModel().add_repo_field(repo, key, field_label=label,
911 911 field_desc=description)
912 912 Session().commit()
913 913 return {
914 914 'msg': "Added new repository field `%s`" % (key,),
915 915 'success': True,
916 916 }
917 917 except Exception:
918 918 log.exception("Exception occurred while trying to add field to repo")
919 919 raise JSONRPCError(
920 920 'failed to create new field for repository `%s`' % (repoid,))
921 921
922 922
923 923 @jsonrpc_method()
924 924 def remove_field_from_repo(request, apiuser, repoid, key):
925 925 """
926 926 Removes an extra field from a repository.
927 927
928 928 This command can only be run using an |authtoken| with at least
929 929 write permissions to the |repo|.
930 930
931 931 :param apiuser: This is filled automatically from the |authtoken|.
932 932 :type apiuser: AuthUser
933 933 :param repoid: Set the repository name or repository ID.
934 934 :type repoid: str or int
935 935 :param key: Set the unique field key for this repository.
936 936 :type key: str
937 937 """
938 938
939 939 repo = get_repo_or_error(repoid)
940 940 if not has_superadmin_permission(apiuser):
941 941 _perms = ('repository.admin',)
942 942 validate_repo_permissions(apiuser, repoid, repo, _perms)
943 943
944 944 field = RepositoryField.get_by_key_name(key, repo)
945 945 if not field:
946 946 raise JSONRPCError('Field with key `%s` does not '
947 947 'exists for repo `%s`' % (key, repoid))
948 948
949 949 try:
950 950 RepoModel().delete_repo_field(repo, field_key=key)
951 951 Session().commit()
952 952 return {
953 953 'msg': "Deleted repository field `%s`" % (key,),
954 954 'success': True,
955 955 }
956 956 except Exception:
957 957 log.exception(
958 958 "Exception occurred while trying to delete field from repo")
959 959 raise JSONRPCError(
960 960 'failed to delete field for repository `%s`' % (repoid,))
961 961
962 962
963 963 @jsonrpc_method()
964 964 def update_repo(
965 965 request, apiuser, repoid, repo_name=Optional(None),
966 966 owner=Optional(OAttr('apiuser')), description=Optional(''),
967 967 private=Optional(False),
968 968 clone_uri=Optional(None), push_uri=Optional(None),
969 969 landing_rev=Optional(None), fork_of=Optional(None),
970 970 enable_statistics=Optional(False),
971 971 enable_locking=Optional(False),
972 972 enable_downloads=Optional(False), fields=Optional('')):
973 973 """
974 974 Updates a repository with the given information.
975 975
976 976 This command can only be run using an |authtoken| with at least
977 977 admin permissions to the |repo|.
978 978
979 979 * If the repository name contains "/", repository will be updated
980 980 accordingly with a repository group or nested repository groups
981 981
982 982 For example repoid=repo-test name="foo/bar/repo-test" will update |repo|
983 983 called "repo-test" and place it inside group "foo/bar".
984 984 You have to have permissions to access and write to the last repository
985 985 group ("bar" in this example)
986 986
987 987 :param apiuser: This is filled automatically from the |authtoken|.
988 988 :type apiuser: AuthUser
989 989 :param repoid: repository name or repository ID.
990 990 :type repoid: str or int
991 991 :param repo_name: Update the |repo| name, including the
992 992 repository group it's in.
993 993 :type repo_name: str
994 994 :param owner: Set the |repo| owner.
995 995 :type owner: str
996 996 :param fork_of: Set the |repo| as fork of another |repo|.
997 997 :type fork_of: str
998 998 :param description: Update the |repo| description.
999 999 :type description: str
1000 1000 :param private: Set the |repo| as private. (True | False)
1001 1001 :type private: bool
1002 1002 :param clone_uri: Update the |repo| clone URI.
1003 1003 :type clone_uri: str
1004 1004 :param landing_rev: Set the |repo| landing revision. e.g branch:default, book:dev, rev:abcd
1005 1005 :type landing_rev: str
1006 1006 :param enable_statistics: Enable statistics on the |repo|, (True | False).
1007 1007 :type enable_statistics: bool
1008 1008 :param enable_locking: Enable |repo| locking.
1009 1009 :type enable_locking: bool
1010 1010 :param enable_downloads: Enable downloads from the |repo|, (True | False).
1011 1011 :type enable_downloads: bool
1012 1012 :param fields: Add extra fields to the |repo|. Use the following
1013 1013 example format: ``field_key=field_val,field_key2=fieldval2``.
1014 1014 Escape ', ' with \,
1015 1015 :type fields: str
1016 1016 """
1017 1017
1018 1018 repo = get_repo_or_error(repoid)
1019 1019
1020 1020 include_secrets = False
1021 1021 if not has_superadmin_permission(apiuser):
1022 1022 _perms = ('repository.admin',)
1023 1023 validate_repo_permissions(apiuser, repoid, repo, _perms)
1024 1024 else:
1025 1025 include_secrets = True
1026 1026
1027 1027 updates = dict(
1028 1028 repo_name=repo_name
1029 1029 if not isinstance(repo_name, Optional) else repo.repo_name,
1030 1030
1031 1031 fork_id=fork_of
1032 1032 if not isinstance(fork_of, Optional) else repo.fork.repo_name if repo.fork else None,
1033 1033
1034 1034 user=owner
1035 1035 if not isinstance(owner, Optional) else repo.user.username,
1036 1036
1037 1037 repo_description=description
1038 1038 if not isinstance(description, Optional) else repo.description,
1039 1039
1040 1040 repo_private=private
1041 1041 if not isinstance(private, Optional) else repo.private,
1042 1042
1043 1043 clone_uri=clone_uri
1044 1044 if not isinstance(clone_uri, Optional) else repo.clone_uri,
1045 1045
1046 1046 push_uri=push_uri
1047 1047 if not isinstance(push_uri, Optional) else repo.push_uri,
1048 1048
1049 1049 repo_landing_rev=landing_rev
1050 1050 if not isinstance(landing_rev, Optional) else repo._landing_revision,
1051 1051
1052 1052 repo_enable_statistics=enable_statistics
1053 1053 if not isinstance(enable_statistics, Optional) else repo.enable_statistics,
1054 1054
1055 1055 repo_enable_locking=enable_locking
1056 1056 if not isinstance(enable_locking, Optional) else repo.enable_locking,
1057 1057
1058 1058 repo_enable_downloads=enable_downloads
1059 1059 if not isinstance(enable_downloads, Optional) else repo.enable_downloads)
1060 1060
1061 1061 landing_ref, _label = ScmModel.backend_landing_ref(repo.repo_type)
1062 1062 ref_choices, _labels = ScmModel().get_repo_landing_revs(
1063 1063 request.translate, repo=repo)
1064 1064 ref_choices = list(set(ref_choices + [landing_ref]))
1065 1065
1066 1066 old_values = repo.get_api_data()
1067 1067 repo_type = repo.repo_type
1068 1068 schema = repo_schema.RepoSchema().bind(
1069 1069 repo_type_options=rhodecode.BACKENDS.keys(),
1070 1070 repo_ref_options=ref_choices,
1071 1071 repo_type=repo_type,
1072 1072 # user caller
1073 1073 user=apiuser,
1074 1074 old_values=old_values)
1075 1075 try:
1076 1076 schema_data = schema.deserialize(dict(
1077 1077 # we save old value, users cannot change type
1078 1078 repo_type=repo_type,
1079 1079
1080 1080 repo_name=updates['repo_name'],
1081 1081 repo_owner=updates['user'],
1082 1082 repo_description=updates['repo_description'],
1083 1083 repo_clone_uri=updates['clone_uri'],
1084 1084 repo_push_uri=updates['push_uri'],
1085 1085 repo_fork_of=updates['fork_id'],
1086 1086 repo_private=updates['repo_private'],
1087 1087 repo_landing_commit_ref=updates['repo_landing_rev'],
1088 1088 repo_enable_statistics=updates['repo_enable_statistics'],
1089 1089 repo_enable_downloads=updates['repo_enable_downloads'],
1090 1090 repo_enable_locking=updates['repo_enable_locking']))
1091 1091 except validation_schema.Invalid as err:
1092 1092 raise JSONRPCValidationError(colander_exc=err)
1093 1093
1094 1094 # save validated data back into the updates dict
1095 1095 validated_updates = dict(
1096 1096 repo_name=schema_data['repo_group']['repo_name_without_group'],
1097 1097 repo_group=schema_data['repo_group']['repo_group_id'],
1098 1098
1099 1099 user=schema_data['repo_owner'],
1100 1100 repo_description=schema_data['repo_description'],
1101 1101 repo_private=schema_data['repo_private'],
1102 1102 clone_uri=schema_data['repo_clone_uri'],
1103 1103 push_uri=schema_data['repo_push_uri'],
1104 1104 repo_landing_rev=schema_data['repo_landing_commit_ref'],
1105 1105 repo_enable_statistics=schema_data['repo_enable_statistics'],
1106 1106 repo_enable_locking=schema_data['repo_enable_locking'],
1107 1107 repo_enable_downloads=schema_data['repo_enable_downloads'],
1108 1108 )
1109 1109
1110 1110 if schema_data['repo_fork_of']:
1111 1111 fork_repo = get_repo_or_error(schema_data['repo_fork_of'])
1112 1112 validated_updates['fork_id'] = fork_repo.repo_id
1113 1113
1114 1114 # extra fields
1115 1115 fields = parse_args(Optional.extract(fields), key_prefix='ex_')
1116 1116 if fields:
1117 1117 validated_updates.update(fields)
1118 1118
1119 1119 try:
1120 1120 RepoModel().update(repo, **validated_updates)
1121 1121 audit_logger.store_api(
1122 1122 'repo.edit', action_data={'old_data': old_values},
1123 1123 user=apiuser, repo=repo)
1124 1124 Session().commit()
1125 1125 return {
1126 1126 'msg': 'updated repo ID:%s %s' % (repo.repo_id, repo.repo_name),
1127 1127 'repository': repo.get_api_data(include_secrets=include_secrets)
1128 1128 }
1129 1129 except Exception:
1130 1130 log.exception(
1131 1131 u"Exception while trying to update the repository %s",
1132 1132 repoid)
1133 1133 raise JSONRPCError('failed to update repo `%s`' % repoid)
1134 1134
1135 1135
1136 1136 @jsonrpc_method()
1137 1137 def fork_repo(request, apiuser, repoid, fork_name,
1138 1138 owner=Optional(OAttr('apiuser')),
1139 1139 description=Optional(''),
1140 1140 private=Optional(False),
1141 1141 clone_uri=Optional(None),
1142 1142 landing_rev=Optional(None),
1143 1143 copy_permissions=Optional(False)):
1144 1144 """
1145 1145 Creates a fork of the specified |repo|.
1146 1146
1147 1147 * If the fork_name contains "/", fork will be created inside
1148 1148 a repository group or nested repository groups
1149 1149
1150 1150 For example "foo/bar/fork-repo" will create fork called "fork-repo"
1151 1151 inside group "foo/bar". You have to have permissions to access and
1152 1152 write to the last repository group ("bar" in this example)
1153 1153
1154 1154 This command can only be run using an |authtoken| with minimum
1155 1155 read permissions of the forked repo, create fork permissions for an user.
1156 1156
1157 1157 :param apiuser: This is filled automatically from the |authtoken|.
1158 1158 :type apiuser: AuthUser
1159 1159 :param repoid: Set repository name or repository ID.
1160 1160 :type repoid: str or int
1161 1161 :param fork_name: Set the fork name, including it's repository group membership.
1162 1162 :type fork_name: str
1163 1163 :param owner: Set the fork owner.
1164 1164 :type owner: str
1165 1165 :param description: Set the fork description.
1166 1166 :type description: str
1167 1167 :param copy_permissions: Copy permissions from parent |repo|. The
1168 1168 default is False.
1169 1169 :type copy_permissions: bool
1170 1170 :param private: Make the fork private. The default is False.
1171 1171 :type private: bool
1172 1172 :param landing_rev: Set the landing revision. E.g branch:default, book:dev, rev:abcd
1173 1173
1174 1174 Example output:
1175 1175
1176 1176 .. code-block:: bash
1177 1177
1178 1178 id : <id_for_response>
1179 1179 api_key : "<api_key>"
1180 1180 args: {
1181 1181 "repoid" : "<reponame or repo_id>",
1182 1182 "fork_name": "<forkname>",
1183 1183 "owner": "<username or user_id = Optional(=apiuser)>",
1184 1184 "description": "<description>",
1185 1185 "copy_permissions": "<bool>",
1186 1186 "private": "<bool>",
1187 1187 "landing_rev": "<landing_rev>"
1188 1188 }
1189 1189
1190 1190 Example error output:
1191 1191
1192 1192 .. code-block:: bash
1193 1193
1194 1194 id : <id_given_in_input>
1195 1195 result: {
1196 1196 "msg": "Created fork of `<reponame>` as `<forkname>`",
1197 1197 "success": true,
1198 1198 "task": "<celery task id or None if done sync>"
1199 1199 }
1200 1200 error: null
1201 1201
1202 1202 """
1203 1203
1204 1204 repo = get_repo_or_error(repoid)
1205 1205 repo_name = repo.repo_name
1206 1206
1207 1207 if not has_superadmin_permission(apiuser):
1208 1208 # check if we have at least read permission for
1209 1209 # this repo that we fork !
1210 1210 _perms = ('repository.admin', 'repository.write', 'repository.read')
1211 1211 validate_repo_permissions(apiuser, repoid, repo, _perms)
1212 1212
1213 1213 # check if the regular user has at least fork permissions as well
1214 1214 if not HasPermissionAnyApi('hg.fork.repository')(user=apiuser):
1215 1215 raise JSONRPCForbidden()
1216 1216
1217 1217 # check if user can set owner parameter
1218 1218 owner = validate_set_owner_permissions(apiuser, owner)
1219 1219
1220 1220 description = Optional.extract(description)
1221 1221 copy_permissions = Optional.extract(copy_permissions)
1222 1222 clone_uri = Optional.extract(clone_uri)
1223 1223
1224 1224 landing_ref, _label = ScmModel.backend_landing_ref(repo.repo_type)
1225 1225 ref_choices, _labels = ScmModel().get_repo_landing_revs(request.translate)
1226 1226 ref_choices = list(set(ref_choices + [landing_ref]))
1227 1227 landing_commit_ref = Optional.extract(landing_rev) or landing_ref
1228 1228
1229 1229 private = Optional.extract(private)
1230 1230
1231 1231 schema = repo_schema.RepoSchema().bind(
1232 1232 repo_type_options=rhodecode.BACKENDS.keys(),
1233 1233 repo_ref_options=ref_choices,
1234 1234 repo_type=repo.repo_type,
1235 1235 # user caller
1236 1236 user=apiuser)
1237 1237
1238 1238 try:
1239 1239 schema_data = schema.deserialize(dict(
1240 1240 repo_name=fork_name,
1241 1241 repo_type=repo.repo_type,
1242 1242 repo_owner=owner.username,
1243 1243 repo_description=description,
1244 1244 repo_landing_commit_ref=landing_commit_ref,
1245 1245 repo_clone_uri=clone_uri,
1246 1246 repo_private=private,
1247 1247 repo_copy_permissions=copy_permissions))
1248 1248 except validation_schema.Invalid as err:
1249 1249 raise JSONRPCValidationError(colander_exc=err)
1250 1250
1251 1251 try:
1252 1252 data = {
1253 1253 'fork_parent_id': repo.repo_id,
1254 1254
1255 1255 'repo_name': schema_data['repo_group']['repo_name_without_group'],
1256 1256 'repo_name_full': schema_data['repo_name'],
1257 1257 'repo_group': schema_data['repo_group']['repo_group_id'],
1258 1258 'repo_type': schema_data['repo_type'],
1259 1259 'description': schema_data['repo_description'],
1260 1260 'private': schema_data['repo_private'],
1261 1261 'copy_permissions': schema_data['repo_copy_permissions'],
1262 1262 'landing_rev': schema_data['repo_landing_commit_ref'],
1263 1263 }
1264 1264
1265 1265 task = RepoModel().create_fork(data, cur_user=owner.user_id)
1266 1266 # no commit, it's done in RepoModel, or async via celery
1267 1267 task_id = get_task_id(task)
1268 1268
1269 1269 return {
1270 1270 'msg': 'Created fork of `%s` as `%s`' % (
1271 1271 repo.repo_name, schema_data['repo_name']),
1272 1272 'success': True, # cannot return the repo data here since fork
1273 1273 # can be done async
1274 1274 'task': task_id
1275 1275 }
1276 1276 except Exception:
1277 1277 log.exception(
1278 1278 u"Exception while trying to create fork %s",
1279 1279 schema_data['repo_name'])
1280 1280 raise JSONRPCError(
1281 1281 'failed to fork repository `%s` as `%s`' % (
1282 1282 repo_name, schema_data['repo_name']))
1283 1283
1284 1284
1285 1285 @jsonrpc_method()
1286 1286 def delete_repo(request, apiuser, repoid, forks=Optional('')):
1287 1287 """
1288 1288 Deletes a repository.
1289 1289
1290 1290 * When the `forks` parameter is set it's possible to detach or delete
1291 1291 forks of deleted repository.
1292 1292
1293 1293 This command can only be run using an |authtoken| with admin
1294 1294 permissions on the |repo|.
1295 1295
1296 1296 :param apiuser: This is filled automatically from the |authtoken|.
1297 1297 :type apiuser: AuthUser
1298 1298 :param repoid: Set the repository name or repository ID.
1299 1299 :type repoid: str or int
1300 1300 :param forks: Set to `detach` or `delete` forks from the |repo|.
1301 1301 :type forks: Optional(str)
1302 1302
1303 1303 Example error output:
1304 1304
1305 1305 .. code-block:: bash
1306 1306
1307 1307 id : <id_given_in_input>
1308 1308 result: {
1309 1309 "msg": "Deleted repository `<reponame>`",
1310 1310 "success": true
1311 1311 }
1312 1312 error: null
1313 1313 """
1314 1314
1315 1315 repo = get_repo_or_error(repoid)
1316 1316 repo_name = repo.repo_name
1317 1317 if not has_superadmin_permission(apiuser):
1318 1318 _perms = ('repository.admin',)
1319 1319 validate_repo_permissions(apiuser, repoid, repo, _perms)
1320 1320
1321 1321 try:
1322 1322 handle_forks = Optional.extract(forks)
1323 1323 _forks_msg = ''
1324 1324 _forks = [f for f in repo.forks]
1325 1325 if handle_forks == 'detach':
1326 1326 _forks_msg = ' ' + 'Detached %s forks' % len(_forks)
1327 1327 elif handle_forks == 'delete':
1328 1328 _forks_msg = ' ' + 'Deleted %s forks' % len(_forks)
1329 1329 elif _forks:
1330 1330 raise JSONRPCError(
1331 1331 'Cannot delete `%s` it still contains attached forks' %
1332 1332 (repo.repo_name,)
1333 1333 )
1334 1334 old_data = repo.get_api_data()
1335 1335 RepoModel().delete(repo, forks=forks)
1336 1336
1337 1337 repo = audit_logger.RepoWrap(repo_id=None,
1338 1338 repo_name=repo.repo_name)
1339 1339
1340 1340 audit_logger.store_api(
1341 1341 'repo.delete', action_data={'old_data': old_data},
1342 1342 user=apiuser, repo=repo)
1343 1343
1344 1344 ScmModel().mark_for_invalidation(repo_name, delete=True)
1345 1345 Session().commit()
1346 1346 return {
1347 1347 'msg': 'Deleted repository `%s`%s' % (repo_name, _forks_msg),
1348 1348 'success': True
1349 1349 }
1350 1350 except Exception:
1351 1351 log.exception("Exception occurred while trying to delete repo")
1352 1352 raise JSONRPCError(
1353 1353 'failed to delete repository `%s`' % (repo_name,)
1354 1354 )
1355 1355
1356 1356
1357 1357 #TODO: marcink, change name ?
1358 1358 @jsonrpc_method()
1359 1359 def invalidate_cache(request, apiuser, repoid, delete_keys=Optional(False)):
1360 1360 """
1361 1361 Invalidates the cache for the specified repository.
1362 1362
1363 1363 This command can only be run using an |authtoken| with admin rights to
1364 1364 the specified repository.
1365 1365
1366 1366 This command takes the following options:
1367 1367
1368 1368 :param apiuser: This is filled automatically from |authtoken|.
1369 1369 :type apiuser: AuthUser
1370 1370 :param repoid: Sets the repository name or repository ID.
1371 1371 :type repoid: str or int
1372 1372 :param delete_keys: This deletes the invalidated keys instead of
1373 1373 just flagging them.
1374 1374 :type delete_keys: Optional(``True`` | ``False``)
1375 1375
1376 1376 Example output:
1377 1377
1378 1378 .. code-block:: bash
1379 1379
1380 1380 id : <id_given_in_input>
1381 1381 result : {
1382 1382 'msg': Cache for repository `<repository name>` was invalidated,
1383 1383 'repository': <repository name>
1384 1384 }
1385 1385 error : null
1386 1386
1387 1387 Example error output:
1388 1388
1389 1389 .. code-block:: bash
1390 1390
1391 1391 id : <id_given_in_input>
1392 1392 result : null
1393 1393 error : {
1394 1394 'Error occurred during cache invalidation action'
1395 1395 }
1396 1396
1397 1397 """
1398 1398
1399 1399 repo = get_repo_or_error(repoid)
1400 1400 if not has_superadmin_permission(apiuser):
1401 1401 _perms = ('repository.admin', 'repository.write',)
1402 1402 validate_repo_permissions(apiuser, repoid, repo, _perms)
1403 1403
1404 1404 delete = Optional.extract(delete_keys)
1405 1405 try:
1406 1406 ScmModel().mark_for_invalidation(repo.repo_name, delete=delete)
1407 1407 return {
1408 1408 'msg': 'Cache for repository `%s` was invalidated' % (repoid,),
1409 1409 'repository': repo.repo_name
1410 1410 }
1411 1411 except Exception:
1412 1412 log.exception(
1413 1413 "Exception occurred while trying to invalidate repo cache")
1414 1414 raise JSONRPCError(
1415 1415 'Error occurred during cache invalidation action'
1416 1416 )
1417 1417
1418 1418
1419 1419 #TODO: marcink, change name ?
1420 1420 @jsonrpc_method()
1421 1421 def lock(request, apiuser, repoid, locked=Optional(None),
1422 1422 userid=Optional(OAttr('apiuser'))):
1423 1423 """
1424 1424 Sets the lock state of the specified |repo| by the given user.
1425 1425 From more information, see :ref:`repo-locking`.
1426 1426
1427 1427 * If the ``userid`` option is not set, the repository is locked to the
1428 1428 user who called the method.
1429 1429 * If the ``locked`` parameter is not set, the current lock state of the
1430 1430 repository is displayed.
1431 1431
1432 1432 This command can only be run using an |authtoken| with admin rights to
1433 1433 the specified repository.
1434 1434
1435 1435 This command takes the following options:
1436 1436
1437 1437 :param apiuser: This is filled automatically from the |authtoken|.
1438 1438 :type apiuser: AuthUser
1439 1439 :param repoid: Sets the repository name or repository ID.
1440 1440 :type repoid: str or int
1441 1441 :param locked: Sets the lock state.
1442 1442 :type locked: Optional(``True`` | ``False``)
1443 1443 :param userid: Set the repository lock to this user.
1444 1444 :type userid: Optional(str or int)
1445 1445
1446 1446 Example error output:
1447 1447
1448 1448 .. code-block:: bash
1449 1449
1450 1450 id : <id_given_in_input>
1451 1451 result : {
1452 1452 'repo': '<reponame>',
1453 1453 'locked': <bool: lock state>,
1454 1454 'locked_since': <int: lock timestamp>,
1455 1455 'locked_by': <username of person who made the lock>,
1456 1456 'lock_reason': <str: reason for locking>,
1457 1457 'lock_state_changed': <bool: True if lock state has been changed in this request>,
1458 1458 'msg': 'Repo `<reponame>` locked by `<username>` on <timestamp>.'
1459 1459 or
1460 1460 'msg': 'Repo `<repository name>` not locked.'
1461 1461 or
1462 1462 'msg': 'User `<user name>` set lock state for repo `<repository name>` to `<new lock state>`'
1463 1463 }
1464 1464 error : null
1465 1465
1466 1466 Example error output:
1467 1467
1468 1468 .. code-block:: bash
1469 1469
1470 1470 id : <id_given_in_input>
1471 1471 result : null
1472 1472 error : {
1473 1473 'Error occurred locking repository `<reponame>`'
1474 1474 }
1475 1475 """
1476 1476
1477 1477 repo = get_repo_or_error(repoid)
1478 1478 if not has_superadmin_permission(apiuser):
1479 1479 # check if we have at least write permission for this repo !
1480 1480 _perms = ('repository.admin', 'repository.write',)
1481 1481 validate_repo_permissions(apiuser, repoid, repo, _perms)
1482 1482
1483 1483 # make sure normal user does not pass someone else userid,
1484 1484 # he is not allowed to do that
1485 1485 if not isinstance(userid, Optional) and userid != apiuser.user_id:
1486 1486 raise JSONRPCError('userid is not the same as your user')
1487 1487
1488 1488 if isinstance(userid, Optional):
1489 1489 userid = apiuser.user_id
1490 1490
1491 1491 user = get_user_or_error(userid)
1492 1492
1493 1493 if isinstance(locked, Optional):
1494 1494 lockobj = repo.locked
1495 1495
1496 1496 if lockobj[0] is None:
1497 1497 _d = {
1498 1498 'repo': repo.repo_name,
1499 1499 'locked': False,
1500 1500 'locked_since': None,
1501 1501 'locked_by': None,
1502 1502 'lock_reason': None,
1503 1503 'lock_state_changed': False,
1504 1504 'msg': 'Repo `%s` not locked.' % repo.repo_name
1505 1505 }
1506 1506 return _d
1507 1507 else:
1508 1508 _user_id, _time, _reason = lockobj
1509 1509 lock_user = get_user_or_error(userid)
1510 1510 _d = {
1511 1511 'repo': repo.repo_name,
1512 1512 'locked': True,
1513 1513 'locked_since': _time,
1514 1514 'locked_by': lock_user.username,
1515 1515 'lock_reason': _reason,
1516 1516 'lock_state_changed': False,
1517 1517 'msg': ('Repo `%s` locked by `%s` on `%s`.'
1518 1518 % (repo.repo_name, lock_user.username,
1519 1519 json.dumps(time_to_datetime(_time))))
1520 1520 }
1521 1521 return _d
1522 1522
1523 1523 # force locked state through a flag
1524 1524 else:
1525 1525 locked = str2bool(locked)
1526 1526 lock_reason = Repository.LOCK_API
1527 1527 try:
1528 1528 if locked:
1529 1529 lock_time = time.time()
1530 1530 Repository.lock(repo, user.user_id, lock_time, lock_reason)
1531 1531 else:
1532 1532 lock_time = None
1533 1533 Repository.unlock(repo)
1534 1534 _d = {
1535 1535 'repo': repo.repo_name,
1536 1536 'locked': locked,
1537 1537 'locked_since': lock_time,
1538 1538 'locked_by': user.username,
1539 1539 'lock_reason': lock_reason,
1540 1540 'lock_state_changed': True,
1541 1541 'msg': ('User `%s` set lock state for repo `%s` to `%s`'
1542 1542 % (user.username, repo.repo_name, locked))
1543 1543 }
1544 1544 return _d
1545 1545 except Exception:
1546 1546 log.exception(
1547 1547 "Exception occurred while trying to lock repository")
1548 1548 raise JSONRPCError(
1549 1549 'Error occurred locking repository `%s`' % repo.repo_name
1550 1550 )
1551 1551
1552 1552
1553 1553 @jsonrpc_method()
1554 1554 def comment_commit(
1555 1555 request, apiuser, repoid, commit_id, message, status=Optional(None),
1556 1556 comment_type=Optional(ChangesetComment.COMMENT_TYPE_NOTE),
1557 1557 resolves_comment_id=Optional(None), extra_recipients=Optional([]),
1558 1558 userid=Optional(OAttr('apiuser')), send_email=Optional(True)):
1559 1559 """
1560 1560 Set a commit comment, and optionally change the status of the commit.
1561 1561
1562 1562 :param apiuser: This is filled automatically from the |authtoken|.
1563 1563 :type apiuser: AuthUser
1564 1564 :param repoid: Set the repository name or repository ID.
1565 1565 :type repoid: str or int
1566 1566 :param commit_id: Specify the commit_id for which to set a comment.
1567 1567 :type commit_id: str
1568 1568 :param message: The comment text.
1569 1569 :type message: str
1570 1570 :param status: (**Optional**) status of commit, one of: 'not_reviewed',
1571 1571 'approved', 'rejected', 'under_review'
1572 1572 :type status: str
1573 1573 :param comment_type: Comment type, one of: 'note', 'todo'
1574 1574 :type comment_type: Optional(str), default: 'note'
1575 1575 :param resolves_comment_id: id of comment which this one will resolve
1576 1576 :type resolves_comment_id: Optional(int)
1577 1577 :param extra_recipients: list of user ids or usernames to add
1578 1578 notifications for this comment. Acts like a CC for notification
1579 1579 :type extra_recipients: Optional(list)
1580 1580 :param userid: Set the user name of the comment creator.
1581 1581 :type userid: Optional(str or int)
1582 1582 :param send_email: Define if this comment should also send email notification
1583 1583 :type send_email: Optional(bool)
1584 1584
1585 1585 Example error output:
1586 1586
1587 1587 .. code-block:: bash
1588 1588
1589 1589 {
1590 1590 "id" : <id_given_in_input>,
1591 1591 "result" : {
1592 1592 "msg": "Commented on commit `<commit_id>` for repository `<repoid>`",
1593 1593 "status_change": null or <status>,
1594 1594 "success": true
1595 1595 },
1596 1596 "error" : null
1597 1597 }
1598 1598
1599 1599 """
1600 _ = request.translate
1601
1600 1602 repo = get_repo_or_error(repoid)
1601 1603 if not has_superadmin_permission(apiuser):
1602 1604 _perms = ('repository.read', 'repository.write', 'repository.admin')
1603 1605 validate_repo_permissions(apiuser, repoid, repo, _perms)
1606 db_repo_name = repo.repo_name
1604 1607
1605 1608 try:
1606 1609 commit = repo.scm_instance().get_commit(commit_id=commit_id)
1607 1610 commit_id = commit.raw_id
1608 1611 except Exception as e:
1609 1612 log.exception('Failed to fetch commit')
1610 1613 raise JSONRPCError(safe_str(e))
1611 1614
1612 1615 if isinstance(userid, Optional):
1613 1616 userid = apiuser.user_id
1614 1617
1615 1618 user = get_user_or_error(userid)
1616 1619 status = Optional.extract(status)
1617 1620 comment_type = Optional.extract(comment_type)
1618 1621 resolves_comment_id = Optional.extract(resolves_comment_id)
1619 1622 extra_recipients = Optional.extract(extra_recipients)
1620 1623 send_email = Optional.extract(send_email, binary=True)
1621 1624
1622 1625 allowed_statuses = [x[0] for x in ChangesetStatus.STATUSES]
1623 1626 if status and status not in allowed_statuses:
1624 1627 raise JSONRPCError('Bad status, must be on '
1625 1628 'of %s got %s' % (allowed_statuses, status,))
1626 1629
1627 1630 if resolves_comment_id:
1628 1631 comment = ChangesetComment.get(resolves_comment_id)
1629 1632 if not comment:
1630 1633 raise JSONRPCError(
1631 1634 'Invalid resolves_comment_id `%s` for this commit.'
1632 1635 % resolves_comment_id)
1633 1636 if comment.comment_type != ChangesetComment.COMMENT_TYPE_TODO:
1634 1637 raise JSONRPCError(
1635 1638 'Comment `%s` is wrong type for setting status to resolved.'
1636 1639 % resolves_comment_id)
1637 1640
1638 1641 try:
1639 1642 rc_config = SettingsModel().get_all_settings()
1640 1643 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
1641 1644 status_change_label = ChangesetStatus.get_status_lbl(status)
1642 1645 comment = CommentsModel().create(
1643 1646 message, repo, user, commit_id=commit_id,
1644 1647 status_change=status_change_label,
1645 1648 status_change_type=status,
1646 1649 renderer=renderer,
1647 1650 comment_type=comment_type,
1648 1651 resolves_comment_id=resolves_comment_id,
1649 1652 auth_user=apiuser,
1650 1653 extra_recipients=extra_recipients,
1651 1654 send_email=send_email
1652 1655 )
1656 is_inline = bool(comment.f_path and comment.line_no)
1657
1653 1658 if status:
1654 1659 # also do a status change
1655 1660 try:
1656 1661 ChangesetStatusModel().set_status(
1657 1662 repo, status, user, comment, revision=commit_id,
1658 1663 dont_allow_on_closed_pull_request=True
1659 1664 )
1660 1665 except StatusChangeOnClosedPullRequestError:
1661 1666 log.exception(
1662 1667 "Exception occurred while trying to change repo commit status")
1663 1668 msg = ('Changing status on a commit associated with '
1664 1669 'a closed pull request is not allowed')
1665 1670 raise JSONRPCError(msg)
1666 1671
1667 1672 CommentsModel().trigger_commit_comment_hook(
1668 1673 repo, apiuser, 'create',
1669 1674 data={'comment': comment, 'commit': commit})
1670 1675
1671 1676 Session().commit()
1677
1678 comment_broadcast_channel = channelstream.comment_channel(
1679 db_repo_name, commit_obj=commit)
1680
1681 comment_data = {'comment': comment, 'comment_id': comment.comment_id}
1682 comment_type = 'inline' if is_inline else 'general'
1683 channelstream.comment_channelstream_push(
1684 request, comment_broadcast_channel, apiuser,
1685 _('posted a new {} comment').format(comment_type),
1686 comment_data=comment_data)
1687
1672 1688 return {
1673 1689 'msg': (
1674 1690 'Commented on commit `%s` for repository `%s`' % (
1675 1691 comment.revision, repo.repo_name)),
1676 1692 'status_change': status,
1677 1693 'success': True,
1678 1694 }
1679 1695 except JSONRPCError:
1680 1696 # catch any inside errors, and re-raise them to prevent from
1681 1697 # below global catch to silence them
1682 1698 raise
1683 1699 except Exception:
1684 1700 log.exception("Exception occurred while trying to comment on commit")
1685 1701 raise JSONRPCError(
1686 1702 'failed to set comment on repository `%s`' % (repo.repo_name,)
1687 1703 )
1688 1704
1689 1705
1690 1706 @jsonrpc_method()
1691 1707 def get_repo_comments(request, apiuser, repoid,
1692 1708 commit_id=Optional(None), comment_type=Optional(None),
1693 1709 userid=Optional(None)):
1694 1710 """
1695 1711 Get all comments for a repository
1696 1712
1697 1713 :param apiuser: This is filled automatically from the |authtoken|.
1698 1714 :type apiuser: AuthUser
1699 1715 :param repoid: Set the repository name or repository ID.
1700 1716 :type repoid: str or int
1701 1717 :param commit_id: Optionally filter the comments by the commit_id
1702 1718 :type commit_id: Optional(str), default: None
1703 1719 :param comment_type: Optionally filter the comments by the comment_type
1704 1720 one of: 'note', 'todo'
1705 1721 :type comment_type: Optional(str), default: None
1706 1722 :param userid: Optionally filter the comments by the author of comment
1707 1723 :type userid: Optional(str or int), Default: None
1708 1724
1709 1725 Example error output:
1710 1726
1711 1727 .. code-block:: bash
1712 1728
1713 1729 {
1714 1730 "id" : <id_given_in_input>,
1715 1731 "result" : [
1716 1732 {
1717 1733 "comment_author": <USER_DETAILS>,
1718 1734 "comment_created_on": "2017-02-01T14:38:16.309",
1719 1735 "comment_f_path": "file.txt",
1720 1736 "comment_id": 282,
1721 1737 "comment_lineno": "n1",
1722 1738 "comment_resolved_by": null,
1723 1739 "comment_status": [],
1724 1740 "comment_text": "This file needs a header",
1725 1741 "comment_type": "todo",
1726 1742 "comment_last_version: 0
1727 1743 }
1728 1744 ],
1729 1745 "error" : null
1730 1746 }
1731 1747
1732 1748 """
1733 1749 repo = get_repo_or_error(repoid)
1734 1750 if not has_superadmin_permission(apiuser):
1735 1751 _perms = ('repository.read', 'repository.write', 'repository.admin')
1736 1752 validate_repo_permissions(apiuser, repoid, repo, _perms)
1737 1753
1738 1754 commit_id = Optional.extract(commit_id)
1739 1755
1740 1756 userid = Optional.extract(userid)
1741 1757 if userid:
1742 1758 user = get_user_or_error(userid)
1743 1759 else:
1744 1760 user = None
1745 1761
1746 1762 comment_type = Optional.extract(comment_type)
1747 1763 if comment_type and comment_type not in ChangesetComment.COMMENT_TYPES:
1748 1764 raise JSONRPCError(
1749 1765 'comment_type must be one of `{}` got {}'.format(
1750 1766 ChangesetComment.COMMENT_TYPES, comment_type)
1751 1767 )
1752 1768
1753 1769 comments = CommentsModel().get_repository_comments(
1754 1770 repo=repo, comment_type=comment_type, user=user, commit_id=commit_id)
1755 1771 return comments
1756 1772
1757 1773
1758 1774 @jsonrpc_method()
1759 1775 def get_comment(request, apiuser, comment_id):
1760 1776 """
1761 1777 Get single comment from repository or pull_request
1762 1778
1763 1779 :param apiuser: This is filled automatically from the |authtoken|.
1764 1780 :type apiuser: AuthUser
1765 1781 :param comment_id: comment id found in the URL of comment
1766 1782 :type comment_id: str or int
1767 1783
1768 1784 Example error output:
1769 1785
1770 1786 .. code-block:: bash
1771 1787
1772 1788 {
1773 1789 "id" : <id_given_in_input>,
1774 1790 "result" : {
1775 1791 "comment_author": <USER_DETAILS>,
1776 1792 "comment_created_on": "2017-02-01T14:38:16.309",
1777 1793 "comment_f_path": "file.txt",
1778 1794 "comment_id": 282,
1779 1795 "comment_lineno": "n1",
1780 1796 "comment_resolved_by": null,
1781 1797 "comment_status": [],
1782 1798 "comment_text": "This file needs a header",
1783 1799 "comment_type": "todo",
1784 1800 "comment_last_version: 0
1785 1801 },
1786 1802 "error" : null
1787 1803 }
1788 1804
1789 1805 """
1790 1806
1791 1807 comment = ChangesetComment.get(comment_id)
1792 1808 if not comment:
1793 1809 raise JSONRPCError('comment `%s` does not exist' % (comment_id,))
1794 1810
1795 1811 perms = ('repository.read', 'repository.write', 'repository.admin')
1796 1812 has_comment_perm = HasRepoPermissionAnyApi(*perms)\
1797 1813 (user=apiuser, repo_name=comment.repo.repo_name)
1798 1814
1799 1815 if not has_comment_perm:
1800 1816 raise JSONRPCError('comment `%s` does not exist' % (comment_id,))
1801 1817
1802 1818 return comment
1803 1819
1804 1820
1805 1821 @jsonrpc_method()
1806 1822 def edit_comment(request, apiuser, message, comment_id, version,
1807 1823 userid=Optional(OAttr('apiuser'))):
1808 1824 """
1809 1825 Edit comment on the pull request or commit,
1810 1826 specified by the `comment_id` and version. Initially version should be 0
1811 1827
1812 1828 :param apiuser: This is filled automatically from the |authtoken|.
1813 1829 :type apiuser: AuthUser
1814 1830 :param comment_id: Specify the comment_id for editing
1815 1831 :type comment_id: int
1816 1832 :param version: version of the comment that will be created, starts from 0
1817 1833 :type version: int
1818 1834 :param message: The text content of the comment.
1819 1835 :type message: str
1820 1836 :param userid: Comment on the pull request as this user
1821 1837 :type userid: Optional(str or int)
1822 1838
1823 1839 Example output:
1824 1840
1825 1841 .. code-block:: bash
1826 1842
1827 1843 id : <id_given_in_input>
1828 1844 result : {
1829 1845 "comment": "<comment data>",
1830 1846 "version": "<Integer>",
1831 1847 },
1832 1848 error : null
1833 1849 """
1834 1850
1835 1851 auth_user = apiuser
1836 1852 comment = ChangesetComment.get(comment_id)
1837 1853 if not comment:
1838 1854 raise JSONRPCError('comment `%s` does not exist' % (comment_id,))
1839 1855
1840 1856 is_super_admin = has_superadmin_permission(apiuser)
1841 1857 is_repo_admin = HasRepoPermissionAnyApi('repository.admin')\
1842 1858 (user=apiuser, repo_name=comment.repo.repo_name)
1843 1859
1844 1860 if not isinstance(userid, Optional):
1845 1861 if is_super_admin or is_repo_admin:
1846 1862 apiuser = get_user_or_error(userid)
1847 1863 auth_user = apiuser.AuthUser()
1848 1864 else:
1849 1865 raise JSONRPCError('userid is not the same as your user')
1850 1866
1851 1867 comment_author = comment.author.user_id == auth_user.user_id
1852 1868 if not (comment.immutable is False and (is_super_admin or is_repo_admin) or comment_author):
1853 1869 raise JSONRPCError("you don't have access to edit this comment")
1854 1870
1855 1871 try:
1856 1872 comment_history = CommentsModel().edit(
1857 1873 comment_id=comment_id,
1858 1874 text=message,
1859 1875 auth_user=auth_user,
1860 1876 version=version,
1861 1877 )
1862 1878 Session().commit()
1863 1879 except CommentVersionMismatch:
1864 1880 raise JSONRPCError(
1865 1881 'comment ({}) version ({}) mismatch'.format(comment_id, version)
1866 1882 )
1867 1883 if not comment_history and not message:
1868 1884 raise JSONRPCError(
1869 1885 "comment ({}) can't be changed with empty string".format(comment_id)
1870 1886 )
1871 1887
1872 1888 if comment.pull_request:
1873 1889 pull_request = comment.pull_request
1874 1890 PullRequestModel().trigger_pull_request_hook(
1875 1891 pull_request, apiuser, 'comment_edit',
1876 1892 data={'comment': comment})
1877 1893 else:
1878 1894 db_repo = comment.repo
1879 1895 commit_id = comment.revision
1880 1896 commit = db_repo.get_commit(commit_id)
1881 1897 CommentsModel().trigger_commit_comment_hook(
1882 1898 db_repo, apiuser, 'edit',
1883 1899 data={'comment': comment, 'commit': commit})
1884 1900
1885 1901 data = {
1886 1902 'comment': comment,
1887 1903 'version': comment_history.version if comment_history else None,
1888 1904 }
1889 1905 return data
1890 1906
1891 1907
1892 1908 # TODO(marcink): write this with all required logic for deleting a comments in PR or commits
1893 1909 # @jsonrpc_method()
1894 1910 # def delete_comment(request, apiuser, comment_id):
1895 1911 # auth_user = apiuser
1896 1912 #
1897 1913 # comment = ChangesetComment.get(comment_id)
1898 1914 # if not comment:
1899 1915 # raise JSONRPCError('comment `%s` does not exist' % (comment_id,))
1900 1916 #
1901 1917 # is_super_admin = has_superadmin_permission(apiuser)
1902 1918 # is_repo_admin = HasRepoPermissionAnyApi('repository.admin')\
1903 1919 # (user=apiuser, repo_name=comment.repo.repo_name)
1904 1920 #
1905 1921 # comment_author = comment.author.user_id == auth_user.user_id
1906 1922 # if not (comment.immutable is False and (is_super_admin or is_repo_admin) or comment_author):
1907 1923 # raise JSONRPCError("you don't have access to edit this comment")
1908 1924
1909 1925 @jsonrpc_method()
1910 1926 def grant_user_permission(request, apiuser, repoid, userid, perm):
1911 1927 """
1912 1928 Grant permissions for the specified user on the given repository,
1913 1929 or update existing permissions if found.
1914 1930
1915 1931 This command can only be run using an |authtoken| with admin
1916 1932 permissions on the |repo|.
1917 1933
1918 1934 :param apiuser: This is filled automatically from the |authtoken|.
1919 1935 :type apiuser: AuthUser
1920 1936 :param repoid: Set the repository name or repository ID.
1921 1937 :type repoid: str or int
1922 1938 :param userid: Set the user name.
1923 1939 :type userid: str
1924 1940 :param perm: Set the user permissions, using the following format
1925 1941 ``(repository.(none|read|write|admin))``
1926 1942 :type perm: str
1927 1943
1928 1944 Example output:
1929 1945
1930 1946 .. code-block:: bash
1931 1947
1932 1948 id : <id_given_in_input>
1933 1949 result: {
1934 1950 "msg" : "Granted perm: `<perm>` for user: `<username>` in repo: `<reponame>`",
1935 1951 "success": true
1936 1952 }
1937 1953 error: null
1938 1954 """
1939 1955
1940 1956 repo = get_repo_or_error(repoid)
1941 1957 user = get_user_or_error(userid)
1942 1958 perm = get_perm_or_error(perm)
1943 1959 if not has_superadmin_permission(apiuser):
1944 1960 _perms = ('repository.admin',)
1945 1961 validate_repo_permissions(apiuser, repoid, repo, _perms)
1946 1962
1947 1963 perm_additions = [[user.user_id, perm.permission_name, "user"]]
1948 1964 try:
1949 1965 changes = RepoModel().update_permissions(
1950 1966 repo=repo, perm_additions=perm_additions, cur_user=apiuser)
1951 1967
1952 1968 action_data = {
1953 1969 'added': changes['added'],
1954 1970 'updated': changes['updated'],
1955 1971 'deleted': changes['deleted'],
1956 1972 }
1957 1973 audit_logger.store_api(
1958 1974 'repo.edit.permissions', action_data=action_data, user=apiuser, repo=repo)
1959 1975 Session().commit()
1960 1976 PermissionModel().flush_user_permission_caches(changes)
1961 1977
1962 1978 return {
1963 1979 'msg': 'Granted perm: `%s` for user: `%s` in repo: `%s`' % (
1964 1980 perm.permission_name, user.username, repo.repo_name
1965 1981 ),
1966 1982 'success': True
1967 1983 }
1968 1984 except Exception:
1969 1985 log.exception("Exception occurred while trying edit permissions for repo")
1970 1986 raise JSONRPCError(
1971 1987 'failed to edit permission for user: `%s` in repo: `%s`' % (
1972 1988 userid, repoid
1973 1989 )
1974 1990 )
1975 1991
1976 1992
1977 1993 @jsonrpc_method()
1978 1994 def revoke_user_permission(request, apiuser, repoid, userid):
1979 1995 """
1980 1996 Revoke permission for a user on the specified repository.
1981 1997
1982 1998 This command can only be run using an |authtoken| with admin
1983 1999 permissions on the |repo|.
1984 2000
1985 2001 :param apiuser: This is filled automatically from the |authtoken|.
1986 2002 :type apiuser: AuthUser
1987 2003 :param repoid: Set the repository name or repository ID.
1988 2004 :type repoid: str or int
1989 2005 :param userid: Set the user name of revoked user.
1990 2006 :type userid: str or int
1991 2007
1992 2008 Example error output:
1993 2009
1994 2010 .. code-block:: bash
1995 2011
1996 2012 id : <id_given_in_input>
1997 2013 result: {
1998 2014 "msg" : "Revoked perm for user: `<username>` in repo: `<reponame>`",
1999 2015 "success": true
2000 2016 }
2001 2017 error: null
2002 2018 """
2003 2019
2004 2020 repo = get_repo_or_error(repoid)
2005 2021 user = get_user_or_error(userid)
2006 2022 if not has_superadmin_permission(apiuser):
2007 2023 _perms = ('repository.admin',)
2008 2024 validate_repo_permissions(apiuser, repoid, repo, _perms)
2009 2025
2010 2026 perm_deletions = [[user.user_id, None, "user"]]
2011 2027 try:
2012 2028 changes = RepoModel().update_permissions(
2013 2029 repo=repo, perm_deletions=perm_deletions, cur_user=user)
2014 2030
2015 2031 action_data = {
2016 2032 'added': changes['added'],
2017 2033 'updated': changes['updated'],
2018 2034 'deleted': changes['deleted'],
2019 2035 }
2020 2036 audit_logger.store_api(
2021 2037 'repo.edit.permissions', action_data=action_data, user=apiuser, repo=repo)
2022 2038 Session().commit()
2023 2039 PermissionModel().flush_user_permission_caches(changes)
2024 2040
2025 2041 return {
2026 2042 'msg': 'Revoked perm for user: `%s` in repo: `%s`' % (
2027 2043 user.username, repo.repo_name
2028 2044 ),
2029 2045 'success': True
2030 2046 }
2031 2047 except Exception:
2032 2048 log.exception("Exception occurred while trying revoke permissions to repo")
2033 2049 raise JSONRPCError(
2034 2050 'failed to edit permission for user: `%s` in repo: `%s`' % (
2035 2051 userid, repoid
2036 2052 )
2037 2053 )
2038 2054
2039 2055
2040 2056 @jsonrpc_method()
2041 2057 def grant_user_group_permission(request, apiuser, repoid, usergroupid, perm):
2042 2058 """
2043 2059 Grant permission for a user group on the specified repository,
2044 2060 or update existing permissions.
2045 2061
2046 2062 This command can only be run using an |authtoken| with admin
2047 2063 permissions on the |repo|.
2048 2064
2049 2065 :param apiuser: This is filled automatically from the |authtoken|.
2050 2066 :type apiuser: AuthUser
2051 2067 :param repoid: Set the repository name or repository ID.
2052 2068 :type repoid: str or int
2053 2069 :param usergroupid: Specify the ID of the user group.
2054 2070 :type usergroupid: str or int
2055 2071 :param perm: Set the user group permissions using the following
2056 2072 format: (repository.(none|read|write|admin))
2057 2073 :type perm: str
2058 2074
2059 2075 Example output:
2060 2076
2061 2077 .. code-block:: bash
2062 2078
2063 2079 id : <id_given_in_input>
2064 2080 result : {
2065 2081 "msg" : "Granted perm: `<perm>` for group: `<usersgroupname>` in repo: `<reponame>`",
2066 2082 "success": true
2067 2083
2068 2084 }
2069 2085 error : null
2070 2086
2071 2087 Example error output:
2072 2088
2073 2089 .. code-block:: bash
2074 2090
2075 2091 id : <id_given_in_input>
2076 2092 result : null
2077 2093 error : {
2078 2094 "failed to edit permission for user group: `<usergroup>` in repo `<repo>`'
2079 2095 }
2080 2096
2081 2097 """
2082 2098
2083 2099 repo = get_repo_or_error(repoid)
2084 2100 perm = get_perm_or_error(perm)
2085 2101 if not has_superadmin_permission(apiuser):
2086 2102 _perms = ('repository.admin',)
2087 2103 validate_repo_permissions(apiuser, repoid, repo, _perms)
2088 2104
2089 2105 user_group = get_user_group_or_error(usergroupid)
2090 2106 if not has_superadmin_permission(apiuser):
2091 2107 # check if we have at least read permission for this user group !
2092 2108 _perms = ('usergroup.read', 'usergroup.write', 'usergroup.admin',)
2093 2109 if not HasUserGroupPermissionAnyApi(*_perms)(
2094 2110 user=apiuser, user_group_name=user_group.users_group_name):
2095 2111 raise JSONRPCError(
2096 2112 'user group `%s` does not exist' % (usergroupid,))
2097 2113
2098 2114 perm_additions = [[user_group.users_group_id, perm.permission_name, "user_group"]]
2099 2115 try:
2100 2116 changes = RepoModel().update_permissions(
2101 2117 repo=repo, perm_additions=perm_additions, cur_user=apiuser)
2102 2118 action_data = {
2103 2119 'added': changes['added'],
2104 2120 'updated': changes['updated'],
2105 2121 'deleted': changes['deleted'],
2106 2122 }
2107 2123 audit_logger.store_api(
2108 2124 'repo.edit.permissions', action_data=action_data, user=apiuser, repo=repo)
2109 2125 Session().commit()
2110 2126 PermissionModel().flush_user_permission_caches(changes)
2111 2127
2112 2128 return {
2113 2129 'msg': 'Granted perm: `%s` for user group: `%s` in '
2114 2130 'repo: `%s`' % (
2115 2131 perm.permission_name, user_group.users_group_name,
2116 2132 repo.repo_name
2117 2133 ),
2118 2134 'success': True
2119 2135 }
2120 2136 except Exception:
2121 2137 log.exception(
2122 2138 "Exception occurred while trying change permission on repo")
2123 2139 raise JSONRPCError(
2124 2140 'failed to edit permission for user group: `%s` in '
2125 2141 'repo: `%s`' % (
2126 2142 usergroupid, repo.repo_name
2127 2143 )
2128 2144 )
2129 2145
2130 2146
2131 2147 @jsonrpc_method()
2132 2148 def revoke_user_group_permission(request, apiuser, repoid, usergroupid):
2133 2149 """
2134 2150 Revoke the permissions of a user group on a given repository.
2135 2151
2136 2152 This command can only be run using an |authtoken| with admin
2137 2153 permissions on the |repo|.
2138 2154
2139 2155 :param apiuser: This is filled automatically from the |authtoken|.
2140 2156 :type apiuser: AuthUser
2141 2157 :param repoid: Set the repository name or repository ID.
2142 2158 :type repoid: str or int
2143 2159 :param usergroupid: Specify the user group ID.
2144 2160 :type usergroupid: str or int
2145 2161
2146 2162 Example output:
2147 2163
2148 2164 .. code-block:: bash
2149 2165
2150 2166 id : <id_given_in_input>
2151 2167 result: {
2152 2168 "msg" : "Revoked perm for group: `<usersgroupname>` in repo: `<reponame>`",
2153 2169 "success": true
2154 2170 }
2155 2171 error: null
2156 2172 """
2157 2173
2158 2174 repo = get_repo_or_error(repoid)
2159 2175 if not has_superadmin_permission(apiuser):
2160 2176 _perms = ('repository.admin',)
2161 2177 validate_repo_permissions(apiuser, repoid, repo, _perms)
2162 2178
2163 2179 user_group = get_user_group_or_error(usergroupid)
2164 2180 if not has_superadmin_permission(apiuser):
2165 2181 # check if we have at least read permission for this user group !
2166 2182 _perms = ('usergroup.read', 'usergroup.write', 'usergroup.admin',)
2167 2183 if not HasUserGroupPermissionAnyApi(*_perms)(
2168 2184 user=apiuser, user_group_name=user_group.users_group_name):
2169 2185 raise JSONRPCError(
2170 2186 'user group `%s` does not exist' % (usergroupid,))
2171 2187
2172 2188 perm_deletions = [[user_group.users_group_id, None, "user_group"]]
2173 2189 try:
2174 2190 changes = RepoModel().update_permissions(
2175 2191 repo=repo, perm_deletions=perm_deletions, cur_user=apiuser)
2176 2192 action_data = {
2177 2193 'added': changes['added'],
2178 2194 'updated': changes['updated'],
2179 2195 'deleted': changes['deleted'],
2180 2196 }
2181 2197 audit_logger.store_api(
2182 2198 'repo.edit.permissions', action_data=action_data, user=apiuser, repo=repo)
2183 2199 Session().commit()
2184 2200 PermissionModel().flush_user_permission_caches(changes)
2185 2201
2186 2202 return {
2187 2203 'msg': 'Revoked perm for user group: `%s` in repo: `%s`' % (
2188 2204 user_group.users_group_name, repo.repo_name
2189 2205 ),
2190 2206 'success': True
2191 2207 }
2192 2208 except Exception:
2193 2209 log.exception("Exception occurred while trying revoke "
2194 2210 "user group permission on repo")
2195 2211 raise JSONRPCError(
2196 2212 'failed to edit permission for user group: `%s` in '
2197 2213 'repo: `%s`' % (
2198 2214 user_group.users_group_name, repo.repo_name
2199 2215 )
2200 2216 )
2201 2217
2202 2218
2203 2219 @jsonrpc_method()
2204 2220 def pull(request, apiuser, repoid, remote_uri=Optional(None)):
2205 2221 """
2206 2222 Triggers a pull on the given repository from a remote location. You
2207 2223 can use this to keep remote repositories up-to-date.
2208 2224
2209 2225 This command can only be run using an |authtoken| with admin
2210 2226 rights to the specified repository. For more information,
2211 2227 see :ref:`config-token-ref`.
2212 2228
2213 2229 This command takes the following options:
2214 2230
2215 2231 :param apiuser: This is filled automatically from the |authtoken|.
2216 2232 :type apiuser: AuthUser
2217 2233 :param repoid: The repository name or repository ID.
2218 2234 :type repoid: str or int
2219 2235 :param remote_uri: Optional remote URI to pass in for pull
2220 2236 :type remote_uri: str
2221 2237
2222 2238 Example output:
2223 2239
2224 2240 .. code-block:: bash
2225 2241
2226 2242 id : <id_given_in_input>
2227 2243 result : {
2228 2244 "msg": "Pulled from url `<remote_url>` on repo `<repository name>`"
2229 2245 "repository": "<repository name>"
2230 2246 }
2231 2247 error : null
2232 2248
2233 2249 Example error output:
2234 2250
2235 2251 .. code-block:: bash
2236 2252
2237 2253 id : <id_given_in_input>
2238 2254 result : null
2239 2255 error : {
2240 2256 "Unable to push changes from `<remote_url>`"
2241 2257 }
2242 2258
2243 2259 """
2244 2260
2245 2261 repo = get_repo_or_error(repoid)
2246 2262 remote_uri = Optional.extract(remote_uri)
2247 2263 remote_uri_display = remote_uri or repo.clone_uri_hidden
2248 2264 if not has_superadmin_permission(apiuser):
2249 2265 _perms = ('repository.admin',)
2250 2266 validate_repo_permissions(apiuser, repoid, repo, _perms)
2251 2267
2252 2268 try:
2253 2269 ScmModel().pull_changes(
2254 2270 repo.repo_name, apiuser.username, remote_uri=remote_uri)
2255 2271 return {
2256 2272 'msg': 'Pulled from url `%s` on repo `%s`' % (
2257 2273 remote_uri_display, repo.repo_name),
2258 2274 'repository': repo.repo_name
2259 2275 }
2260 2276 except Exception:
2261 2277 log.exception("Exception occurred while trying to "
2262 2278 "pull changes from remote location")
2263 2279 raise JSONRPCError(
2264 2280 'Unable to pull changes from `%s`' % remote_uri_display
2265 2281 )
2266 2282
2267 2283
2268 2284 @jsonrpc_method()
2269 2285 def strip(request, apiuser, repoid, revision, branch):
2270 2286 """
2271 2287 Strips the given revision from the specified repository.
2272 2288
2273 2289 * This will remove the revision and all of its decendants.
2274 2290
2275 2291 This command can only be run using an |authtoken| with admin rights to
2276 2292 the specified repository.
2277 2293
2278 2294 This command takes the following options:
2279 2295
2280 2296 :param apiuser: This is filled automatically from the |authtoken|.
2281 2297 :type apiuser: AuthUser
2282 2298 :param repoid: The repository name or repository ID.
2283 2299 :type repoid: str or int
2284 2300 :param revision: The revision you wish to strip.
2285 2301 :type revision: str
2286 2302 :param branch: The branch from which to strip the revision.
2287 2303 :type branch: str
2288 2304
2289 2305 Example output:
2290 2306
2291 2307 .. code-block:: bash
2292 2308
2293 2309 id : <id_given_in_input>
2294 2310 result : {
2295 2311 "msg": "'Stripped commit <commit_hash> from repo `<repository name>`'"
2296 2312 "repository": "<repository name>"
2297 2313 }
2298 2314 error : null
2299 2315
2300 2316 Example error output:
2301 2317
2302 2318 .. code-block:: bash
2303 2319
2304 2320 id : <id_given_in_input>
2305 2321 result : null
2306 2322 error : {
2307 2323 "Unable to strip commit <commit_hash> from repo `<repository name>`"
2308 2324 }
2309 2325
2310 2326 """
2311 2327
2312 2328 repo = get_repo_or_error(repoid)
2313 2329 if not has_superadmin_permission(apiuser):
2314 2330 _perms = ('repository.admin',)
2315 2331 validate_repo_permissions(apiuser, repoid, repo, _perms)
2316 2332
2317 2333 try:
2318 2334 ScmModel().strip(repo, revision, branch)
2319 2335 audit_logger.store_api(
2320 2336 'repo.commit.strip', action_data={'commit_id': revision},
2321 2337 repo=repo,
2322 2338 user=apiuser, commit=True)
2323 2339
2324 2340 return {
2325 2341 'msg': 'Stripped commit %s from repo `%s`' % (
2326 2342 revision, repo.repo_name),
2327 2343 'repository': repo.repo_name
2328 2344 }
2329 2345 except Exception:
2330 2346 log.exception("Exception while trying to strip")
2331 2347 raise JSONRPCError(
2332 2348 'Unable to strip commit %s from repo `%s`' % (
2333 2349 revision, repo.repo_name)
2334 2350 )
2335 2351
2336 2352
2337 2353 @jsonrpc_method()
2338 2354 def get_repo_settings(request, apiuser, repoid, key=Optional(None)):
2339 2355 """
2340 2356 Returns all settings for a repository. If key is given it only returns the
2341 2357 setting identified by the key or null.
2342 2358
2343 2359 :param apiuser: This is filled automatically from the |authtoken|.
2344 2360 :type apiuser: AuthUser
2345 2361 :param repoid: The repository name or repository id.
2346 2362 :type repoid: str or int
2347 2363 :param key: Key of the setting to return.
2348 2364 :type: key: Optional(str)
2349 2365
2350 2366 Example output:
2351 2367
2352 2368 .. code-block:: bash
2353 2369
2354 2370 {
2355 2371 "error": null,
2356 2372 "id": 237,
2357 2373 "result": {
2358 2374 "extensions_largefiles": true,
2359 2375 "extensions_evolve": true,
2360 2376 "hooks_changegroup_push_logger": true,
2361 2377 "hooks_changegroup_repo_size": false,
2362 2378 "hooks_outgoing_pull_logger": true,
2363 2379 "phases_publish": "True",
2364 2380 "rhodecode_hg_use_rebase_for_merging": true,
2365 2381 "rhodecode_pr_merge_enabled": true,
2366 2382 "rhodecode_use_outdated_comments": true
2367 2383 }
2368 2384 }
2369 2385 """
2370 2386
2371 2387 # Restrict access to this api method to super-admins, and repo admins only.
2372 2388 repo = get_repo_or_error(repoid)
2373 2389 if not has_superadmin_permission(apiuser):
2374 2390 _perms = ('repository.admin',)
2375 2391 validate_repo_permissions(apiuser, repoid, repo, _perms)
2376 2392
2377 2393 try:
2378 2394 settings_model = VcsSettingsModel(repo=repo)
2379 2395 settings = settings_model.get_global_settings()
2380 2396 settings.update(settings_model.get_repo_settings())
2381 2397
2382 2398 # If only a single setting is requested fetch it from all settings.
2383 2399 key = Optional.extract(key)
2384 2400 if key is not None:
2385 2401 settings = settings.get(key, None)
2386 2402 except Exception:
2387 2403 msg = 'Failed to fetch settings for repository `{}`'.format(repoid)
2388 2404 log.exception(msg)
2389 2405 raise JSONRPCError(msg)
2390 2406
2391 2407 return settings
2392 2408
2393 2409
2394 2410 @jsonrpc_method()
2395 2411 def set_repo_settings(request, apiuser, repoid, settings):
2396 2412 """
2397 2413 Update repository settings. Returns true on success.
2398 2414
2399 2415 :param apiuser: This is filled automatically from the |authtoken|.
2400 2416 :type apiuser: AuthUser
2401 2417 :param repoid: The repository name or repository id.
2402 2418 :type repoid: str or int
2403 2419 :param settings: The new settings for the repository.
2404 2420 :type: settings: dict
2405 2421
2406 2422 Example output:
2407 2423
2408 2424 .. code-block:: bash
2409 2425
2410 2426 {
2411 2427 "error": null,
2412 2428 "id": 237,
2413 2429 "result": true
2414 2430 }
2415 2431 """
2416 2432 # Restrict access to this api method to super-admins, and repo admins only.
2417 2433 repo = get_repo_or_error(repoid)
2418 2434 if not has_superadmin_permission(apiuser):
2419 2435 _perms = ('repository.admin',)
2420 2436 validate_repo_permissions(apiuser, repoid, repo, _perms)
2421 2437
2422 2438 if type(settings) is not dict:
2423 2439 raise JSONRPCError('Settings have to be a JSON Object.')
2424 2440
2425 2441 try:
2426 2442 settings_model = VcsSettingsModel(repo=repoid)
2427 2443
2428 2444 # Merge global, repo and incoming settings.
2429 2445 new_settings = settings_model.get_global_settings()
2430 2446 new_settings.update(settings_model.get_repo_settings())
2431 2447 new_settings.update(settings)
2432 2448
2433 2449 # Update the settings.
2434 2450 inherit_global_settings = new_settings.get(
2435 2451 'inherit_global_settings', False)
2436 2452 settings_model.create_or_update_repo_settings(
2437 2453 new_settings, inherit_global_settings=inherit_global_settings)
2438 2454 Session().commit()
2439 2455 except Exception:
2440 2456 msg = 'Failed to update settings for repository `{}`'.format(repoid)
2441 2457 log.exception(msg)
2442 2458 raise JSONRPCError(msg)
2443 2459
2444 2460 # Indicate success.
2445 2461 return True
2446 2462
2447 2463
2448 2464 @jsonrpc_method()
2449 2465 def maintenance(request, apiuser, repoid):
2450 2466 """
2451 2467 Triggers a maintenance on the given repository.
2452 2468
2453 2469 This command can only be run using an |authtoken| with admin
2454 2470 rights to the specified repository. For more information,
2455 2471 see :ref:`config-token-ref`.
2456 2472
2457 2473 This command takes the following options:
2458 2474
2459 2475 :param apiuser: This is filled automatically from the |authtoken|.
2460 2476 :type apiuser: AuthUser
2461 2477 :param repoid: The repository name or repository ID.
2462 2478 :type repoid: str or int
2463 2479
2464 2480 Example output:
2465 2481
2466 2482 .. code-block:: bash
2467 2483
2468 2484 id : <id_given_in_input>
2469 2485 result : {
2470 2486 "msg": "executed maintenance command",
2471 2487 "executed_actions": [
2472 2488 <action_message>, <action_message2>...
2473 2489 ],
2474 2490 "repository": "<repository name>"
2475 2491 }
2476 2492 error : null
2477 2493
2478 2494 Example error output:
2479 2495
2480 2496 .. code-block:: bash
2481 2497
2482 2498 id : <id_given_in_input>
2483 2499 result : null
2484 2500 error : {
2485 2501 "Unable to execute maintenance on `<reponame>`"
2486 2502 }
2487 2503
2488 2504 """
2489 2505
2490 2506 repo = get_repo_or_error(repoid)
2491 2507 if not has_superadmin_permission(apiuser):
2492 2508 _perms = ('repository.admin',)
2493 2509 validate_repo_permissions(apiuser, repoid, repo, _perms)
2494 2510
2495 2511 try:
2496 2512 maintenance = repo_maintenance.RepoMaintenance()
2497 2513 executed_actions = maintenance.execute(repo)
2498 2514
2499 2515 return {
2500 2516 'msg': 'executed maintenance command',
2501 2517 'executed_actions': executed_actions,
2502 2518 'repository': repo.repo_name
2503 2519 }
2504 2520 except Exception:
2505 2521 log.exception("Exception occurred while trying to run maintenance")
2506 2522 raise JSONRPCError(
2507 2523 'Unable to execute maintenance on `%s`' % repo.repo_name)
@@ -1,784 +1,792 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import collections
23 23
24 24 from pyramid.httpexceptions import (
25 25 HTTPNotFound, HTTPBadRequest, HTTPFound, HTTPForbidden, HTTPConflict)
26 26 from pyramid.view import view_config
27 27 from pyramid.renderers import render
28 28 from pyramid.response import Response
29 29
30 30 from rhodecode.apps._base import RepoAppView
31 31 from rhodecode.apps.file_store import utils as store_utils
32 32 from rhodecode.apps.file_store.exceptions import FileNotAllowedException, FileOverSizeException
33 33
34 from rhodecode.lib import diffs, codeblocks
34 from rhodecode.lib import diffs, codeblocks, channelstream
35 35 from rhodecode.lib.auth import (
36 36 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous, CSRFRequired)
37 37 from rhodecode.lib.ext_json import json
38 38 from rhodecode.lib.compat import OrderedDict
39 39 from rhodecode.lib.diffs import (
40 40 cache_diff, load_cached_diff, diff_cache_exist, get_diff_context,
41 41 get_diff_whitespace_flag)
42 42 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError, CommentVersionMismatch
43 43 import rhodecode.lib.helpers as h
44 44 from rhodecode.lib.utils2 import safe_unicode, str2bool, StrictAttributeDict
45 45 from rhodecode.lib.vcs.backends.base import EmptyCommit
46 46 from rhodecode.lib.vcs.exceptions import (
47 47 RepositoryError, CommitDoesNotExistError)
48 48 from rhodecode.model.db import ChangesetComment, ChangesetStatus, FileStore, \
49 49 ChangesetCommentHistory
50 50 from rhodecode.model.changeset_status import ChangesetStatusModel
51 51 from rhodecode.model.comment import CommentsModel
52 52 from rhodecode.model.meta import Session
53 53 from rhodecode.model.settings import VcsSettingsModel
54 54
55 55 log = logging.getLogger(__name__)
56 56
57 57
58 58 def _update_with_GET(params, request):
59 59 for k in ['diff1', 'diff2', 'diff']:
60 60 params[k] += request.GET.getall(k)
61 61
62 62
63 63 class RepoCommitsView(RepoAppView):
64 64 def load_default_context(self):
65 65 c = self._get_local_tmpl_context(include_app_defaults=True)
66 66 c.rhodecode_repo = self.rhodecode_vcs_repo
67 67
68 68 return c
69 69
70 70 def _is_diff_cache_enabled(self, target_repo):
71 71 caching_enabled = self._get_general_setting(
72 72 target_repo, 'rhodecode_diff_cache')
73 73 log.debug('Diff caching enabled: %s', caching_enabled)
74 74 return caching_enabled
75 75
76 76 def _commit(self, commit_id_range, method):
77 77 _ = self.request.translate
78 78 c = self.load_default_context()
79 79 c.fulldiff = self.request.GET.get('fulldiff')
80 80
81 81 # fetch global flags of ignore ws or context lines
82 82 diff_context = get_diff_context(self.request)
83 83 hide_whitespace_changes = get_diff_whitespace_flag(self.request)
84 84
85 85 # diff_limit will cut off the whole diff if the limit is applied
86 86 # otherwise it will just hide the big files from the front-end
87 87 diff_limit = c.visual.cut_off_limit_diff
88 88 file_limit = c.visual.cut_off_limit_file
89 89
90 90 # get ranges of commit ids if preset
91 91 commit_range = commit_id_range.split('...')[:2]
92 92
93 93 try:
94 94 pre_load = ['affected_files', 'author', 'branch', 'date',
95 95 'message', 'parents']
96 96 if self.rhodecode_vcs_repo.alias == 'hg':
97 97 pre_load += ['hidden', 'obsolete', 'phase']
98 98
99 99 if len(commit_range) == 2:
100 100 commits = self.rhodecode_vcs_repo.get_commits(
101 101 start_id=commit_range[0], end_id=commit_range[1],
102 102 pre_load=pre_load, translate_tags=False)
103 103 commits = list(commits)
104 104 else:
105 105 commits = [self.rhodecode_vcs_repo.get_commit(
106 106 commit_id=commit_id_range, pre_load=pre_load)]
107 107
108 108 c.commit_ranges = commits
109 109 if not c.commit_ranges:
110 110 raise RepositoryError('The commit range returned an empty result')
111 111 except CommitDoesNotExistError as e:
112 112 msg = _('No such commit exists. Org exception: `{}`').format(e)
113 113 h.flash(msg, category='error')
114 114 raise HTTPNotFound()
115 115 except Exception:
116 116 log.exception("General failure")
117 117 raise HTTPNotFound()
118 118 single_commit = len(c.commit_ranges) == 1
119 119
120 120 c.changes = OrderedDict()
121 121 c.lines_added = 0
122 122 c.lines_deleted = 0
123 123
124 124 # auto collapse if we have more than limit
125 125 collapse_limit = diffs.DiffProcessor._collapse_commits_over
126 126 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
127 127
128 128 c.commit_statuses = ChangesetStatus.STATUSES
129 129 c.inline_comments = []
130 130 c.files = []
131 131
132 132 c.comments = []
133 133 c.unresolved_comments = []
134 134 c.resolved_comments = []
135 135
136 136 # Single commit
137 137 if single_commit:
138 138 commit = c.commit_ranges[0]
139 139 c.comments = CommentsModel().get_comments(
140 140 self.db_repo.repo_id,
141 141 revision=commit.raw_id)
142 142
143 143 # comments from PR
144 144 statuses = ChangesetStatusModel().get_statuses(
145 145 self.db_repo.repo_id, commit.raw_id,
146 146 with_revisions=True)
147 147
148 148 prs = set()
149 149 reviewers = list()
150 150 reviewers_duplicates = set() # to not have duplicates from multiple votes
151 151 for c_status in statuses:
152 152
153 153 # extract associated pull-requests from votes
154 154 if c_status.pull_request:
155 155 prs.add(c_status.pull_request)
156 156
157 157 # extract reviewers
158 158 _user_id = c_status.author.user_id
159 159 if _user_id not in reviewers_duplicates:
160 160 reviewers.append(
161 161 StrictAttributeDict({
162 162 'user': c_status.author,
163 163
164 164 # fake attributed for commit, page that we don't have
165 165 # but we share the display with PR page
166 166 'mandatory': False,
167 167 'reasons': [],
168 168 'rule_user_group_data': lambda: None
169 169 })
170 170 )
171 171 reviewers_duplicates.add(_user_id)
172 172
173 173 c.allowed_reviewers = reviewers
174 174 c.reviewers_count = len(reviewers)
175 175 c.observers_count = 0
176 176
177 177 # from associated statuses, check the pull requests, and
178 178 # show comments from them
179 179 for pr in prs:
180 180 c.comments.extend(pr.comments)
181 181
182 182 c.unresolved_comments = CommentsModel()\
183 183 .get_commit_unresolved_todos(commit.raw_id)
184 184 c.resolved_comments = CommentsModel()\
185 185 .get_commit_resolved_todos(commit.raw_id)
186 186
187 187 c.inline_comments_flat = CommentsModel()\
188 188 .get_commit_inline_comments(commit.raw_id)
189 189
190 190 review_statuses = ChangesetStatusModel().aggregate_votes_by_user(
191 191 statuses, reviewers)
192 192
193 193 c.commit_review_status = ChangesetStatus.STATUS_NOT_REVIEWED
194 194
195 195 c.commit_set_reviewers_data_json = collections.OrderedDict({'reviewers': []})
196 196
197 197 for review_obj, member, reasons, mandatory, status in review_statuses:
198 198 member_reviewer = h.reviewer_as_json(
199 199 member, reasons=reasons, mandatory=mandatory, role=None,
200 200 user_group=None
201 201 )
202 202
203 203 current_review_status = status[0][1].status if status else ChangesetStatus.STATUS_NOT_REVIEWED
204 204 member_reviewer['review_status'] = current_review_status
205 205 member_reviewer['review_status_label'] = h.commit_status_lbl(current_review_status)
206 206 member_reviewer['allowed_to_update'] = False
207 207 c.commit_set_reviewers_data_json['reviewers'].append(member_reviewer)
208 208
209 209 c.commit_set_reviewers_data_json = json.dumps(c.commit_set_reviewers_data_json)
210 210
211 211 # NOTE(marcink): this uses the same voting logic as in pull-requests
212 212 c.commit_review_status = ChangesetStatusModel().calculate_status(review_statuses)
213 c.commit_broadcast_channel = u'/repo${}$/commit/{}'.format(
214 c.repo_name,
215 commit.raw_id
216 )
213 c.commit_broadcast_channel = channelstream.comment_channel(c.repo_name, commit_obj=commit)
217 214
218 215 diff = None
219 216 # Iterate over ranges (default commit view is always one commit)
220 217 for commit in c.commit_ranges:
221 218 c.changes[commit.raw_id] = []
222 219
223 220 commit2 = commit
224 221 commit1 = commit.first_parent
225 222
226 223 if method == 'show':
227 224 inline_comments = CommentsModel().get_inline_comments(
228 225 self.db_repo.repo_id, revision=commit.raw_id)
229 226 c.inline_cnt = len(CommentsModel().get_inline_comments_as_list(
230 227 inline_comments))
231 228 c.inline_comments = inline_comments
232 229
233 230 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(
234 231 self.db_repo)
235 232 cache_file_path = diff_cache_exist(
236 233 cache_path, 'diff', commit.raw_id,
237 234 hide_whitespace_changes, diff_context, c.fulldiff)
238 235
239 236 caching_enabled = self._is_diff_cache_enabled(self.db_repo)
240 237 force_recache = str2bool(self.request.GET.get('force_recache'))
241 238
242 239 cached_diff = None
243 240 if caching_enabled:
244 241 cached_diff = load_cached_diff(cache_file_path)
245 242
246 243 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
247 244 if not force_recache and has_proper_diff_cache:
248 245 diffset = cached_diff['diff']
249 246 else:
250 247 vcs_diff = self.rhodecode_vcs_repo.get_diff(
251 248 commit1, commit2,
252 249 ignore_whitespace=hide_whitespace_changes,
253 250 context=diff_context)
254 251
255 252 diff_processor = diffs.DiffProcessor(
256 253 vcs_diff, format='newdiff', diff_limit=diff_limit,
257 254 file_limit=file_limit, show_full_diff=c.fulldiff)
258 255
259 256 _parsed = diff_processor.prepare()
260 257
261 258 diffset = codeblocks.DiffSet(
262 259 repo_name=self.db_repo_name,
263 260 source_node_getter=codeblocks.diffset_node_getter(commit1),
264 261 target_node_getter=codeblocks.diffset_node_getter(commit2))
265 262
266 263 diffset = self.path_filter.render_patchset_filtered(
267 264 diffset, _parsed, commit1.raw_id, commit2.raw_id)
268 265
269 266 # save cached diff
270 267 if caching_enabled:
271 268 cache_diff(cache_file_path, diffset, None)
272 269
273 270 c.limited_diff = diffset.limited_diff
274 271 c.changes[commit.raw_id] = diffset
275 272 else:
276 273 # TODO(marcink): no cache usage here...
277 274 _diff = self.rhodecode_vcs_repo.get_diff(
278 275 commit1, commit2,
279 276 ignore_whitespace=hide_whitespace_changes, context=diff_context)
280 277 diff_processor = diffs.DiffProcessor(
281 278 _diff, format='newdiff', diff_limit=diff_limit,
282 279 file_limit=file_limit, show_full_diff=c.fulldiff)
283 280 # downloads/raw we only need RAW diff nothing else
284 281 diff = self.path_filter.get_raw_patch(diff_processor)
285 282 c.changes[commit.raw_id] = [None, None, None, None, diff, None, None]
286 283
287 284 # sort comments by how they were generated
288 285 c.comments = sorted(c.comments, key=lambda x: x.comment_id)
289 286 c.at_version_num = None
290 287
291 288 if len(c.commit_ranges) == 1:
292 289 c.commit = c.commit_ranges[0]
293 290 c.parent_tmpl = ''.join(
294 291 '# Parent %s\n' % x.raw_id for x in c.commit.parents)
295 292
296 293 if method == 'download':
297 294 response = Response(diff)
298 295 response.content_type = 'text/plain'
299 296 response.content_disposition = (
300 297 'attachment; filename=%s.diff' % commit_id_range[:12])
301 298 return response
302 299 elif method == 'patch':
303 300 c.diff = safe_unicode(diff)
304 301 patch = render(
305 302 'rhodecode:templates/changeset/patch_changeset.mako',
306 303 self._get_template_context(c), self.request)
307 304 response = Response(patch)
308 305 response.content_type = 'text/plain'
309 306 return response
310 307 elif method == 'raw':
311 308 response = Response(diff)
312 309 response.content_type = 'text/plain'
313 310 return response
314 311 elif method == 'show':
315 312 if len(c.commit_ranges) == 1:
316 313 html = render(
317 314 'rhodecode:templates/changeset/changeset.mako',
318 315 self._get_template_context(c), self.request)
319 316 return Response(html)
320 317 else:
321 318 c.ancestor = None
322 319 c.target_repo = self.db_repo
323 320 html = render(
324 321 'rhodecode:templates/changeset/changeset_range.mako',
325 322 self._get_template_context(c), self.request)
326 323 return Response(html)
327 324
328 325 raise HTTPBadRequest()
329 326
330 327 @LoginRequired()
331 328 @HasRepoPermissionAnyDecorator(
332 329 'repository.read', 'repository.write', 'repository.admin')
333 330 @view_config(
334 331 route_name='repo_commit', request_method='GET',
335 332 renderer=None)
336 333 def repo_commit_show(self):
337 334 commit_id = self.request.matchdict['commit_id']
338 335 return self._commit(commit_id, method='show')
339 336
340 337 @LoginRequired()
341 338 @HasRepoPermissionAnyDecorator(
342 339 'repository.read', 'repository.write', 'repository.admin')
343 340 @view_config(
344 341 route_name='repo_commit_raw', request_method='GET',
345 342 renderer=None)
346 343 @view_config(
347 344 route_name='repo_commit_raw_deprecated', request_method='GET',
348 345 renderer=None)
349 346 def repo_commit_raw(self):
350 347 commit_id = self.request.matchdict['commit_id']
351 348 return self._commit(commit_id, method='raw')
352 349
353 350 @LoginRequired()
354 351 @HasRepoPermissionAnyDecorator(
355 352 'repository.read', 'repository.write', 'repository.admin')
356 353 @view_config(
357 354 route_name='repo_commit_patch', request_method='GET',
358 355 renderer=None)
359 356 def repo_commit_patch(self):
360 357 commit_id = self.request.matchdict['commit_id']
361 358 return self._commit(commit_id, method='patch')
362 359
363 360 @LoginRequired()
364 361 @HasRepoPermissionAnyDecorator(
365 362 'repository.read', 'repository.write', 'repository.admin')
366 363 @view_config(
367 364 route_name='repo_commit_download', request_method='GET',
368 365 renderer=None)
369 366 def repo_commit_download(self):
370 367 commit_id = self.request.matchdict['commit_id']
371 368 return self._commit(commit_id, method='download')
372 369
373 370 @LoginRequired()
374 371 @NotAnonymous()
375 372 @HasRepoPermissionAnyDecorator(
376 373 'repository.read', 'repository.write', 'repository.admin')
377 374 @CSRFRequired()
378 375 @view_config(
379 376 route_name='repo_commit_comment_create', request_method='POST',
380 377 renderer='json_ext')
381 378 def repo_commit_comment_create(self):
382 379 _ = self.request.translate
383 380 commit_id = self.request.matchdict['commit_id']
384 381
385 382 c = self.load_default_context()
386 383 status = self.request.POST.get('changeset_status', None)
387 384 text = self.request.POST.get('text')
388 385 comment_type = self.request.POST.get('comment_type')
389 386 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
390 387
391 388 if status:
392 389 text = text or (_('Status change %(transition_icon)s %(status)s')
393 390 % {'transition_icon': '>',
394 391 'status': ChangesetStatus.get_status_lbl(status)})
395 392
396 393 multi_commit_ids = []
397 394 for _commit_id in self.request.POST.get('commit_ids', '').split(','):
398 395 if _commit_id not in ['', None, EmptyCommit.raw_id]:
399 396 if _commit_id not in multi_commit_ids:
400 397 multi_commit_ids.append(_commit_id)
401 398
402 399 commit_ids = multi_commit_ids or [commit_id]
403 400
404 401 comment = None
405 402 for current_id in filter(None, commit_ids):
406 403 comment = CommentsModel().create(
407 404 text=text,
408 405 repo=self.db_repo.repo_id,
409 406 user=self._rhodecode_db_user.user_id,
410 407 commit_id=current_id,
411 408 f_path=self.request.POST.get('f_path'),
412 409 line_no=self.request.POST.get('line'),
413 410 status_change=(ChangesetStatus.get_status_lbl(status)
414 411 if status else None),
415 412 status_change_type=status,
416 413 comment_type=comment_type,
417 414 resolves_comment_id=resolves_comment_id,
418 415 auth_user=self._rhodecode_user
419 416 )
417 is_inline = bool(comment.f_path and comment.line_no)
420 418
421 419 # get status if set !
422 420 if status:
423 421 # if latest status was from pull request and it's closed
424 422 # disallow changing status !
425 423 # dont_allow_on_closed_pull_request = True !
426 424
427 425 try:
428 426 ChangesetStatusModel().set_status(
429 427 self.db_repo.repo_id,
430 428 status,
431 429 self._rhodecode_db_user.user_id,
432 430 comment,
433 431 revision=current_id,
434 432 dont_allow_on_closed_pull_request=True
435 433 )
436 434 except StatusChangeOnClosedPullRequestError:
437 435 msg = _('Changing the status of a commit associated with '
438 436 'a closed pull request is not allowed')
439 437 log.exception(msg)
440 438 h.flash(msg, category='warning')
441 439 raise HTTPFound(h.route_path(
442 440 'repo_commit', repo_name=self.db_repo_name,
443 441 commit_id=current_id))
444 442
445 443 commit = self.db_repo.get_commit(current_id)
446 444 CommentsModel().trigger_commit_comment_hook(
447 445 self.db_repo, self._rhodecode_user, 'create',
448 446 data={'comment': comment, 'commit': commit})
449 447
450 448 # finalize, commit and redirect
451 449 Session().commit()
452 450
453 451 data = {
454 452 'target_id': h.safeid(h.safe_unicode(
455 453 self.request.POST.get('f_path'))),
456 454 }
457 455 if comment:
458 456 c.co = comment
459 457 c.at_version_num = 0
460 458 rendered_comment = render(
461 459 'rhodecode:templates/changeset/changeset_comment_block.mako',
462 460 self._get_template_context(c), self.request)
463 461
464 462 data.update(comment.get_dict())
465 463 data.update({'rendered_text': rendered_comment})
466 464
465 comment_broadcast_channel = channelstream.comment_channel(
466 self.db_repo_name, commit_obj=commit)
467
468 comment_data = data
469 comment_type = 'inline' if is_inline else 'general'
470 channelstream.comment_channelstream_push(
471 self.request, comment_broadcast_channel, self._rhodecode_user,
472 _('posted a new {} comment').format(comment_type),
473 comment_data=comment_data)
474
467 475 return data
468 476
469 477 @LoginRequired()
470 478 @NotAnonymous()
471 479 @HasRepoPermissionAnyDecorator(
472 480 'repository.read', 'repository.write', 'repository.admin')
473 481 @CSRFRequired()
474 482 @view_config(
475 483 route_name='repo_commit_comment_preview', request_method='POST',
476 484 renderer='string', xhr=True)
477 485 def repo_commit_comment_preview(self):
478 486 # Technically a CSRF token is not needed as no state changes with this
479 487 # call. However, as this is a POST is better to have it, so automated
480 488 # tools don't flag it as potential CSRF.
481 489 # Post is required because the payload could be bigger than the maximum
482 490 # allowed by GET.
483 491
484 492 text = self.request.POST.get('text')
485 493 renderer = self.request.POST.get('renderer') or 'rst'
486 494 if text:
487 495 return h.render(text, renderer=renderer, mentions=True,
488 496 repo_name=self.db_repo_name)
489 497 return ''
490 498
491 499 @LoginRequired()
492 500 @HasRepoPermissionAnyDecorator(
493 501 'repository.read', 'repository.write', 'repository.admin')
494 502 @CSRFRequired()
495 503 @view_config(
496 504 route_name='repo_commit_comment_history_view', request_method='POST',
497 505 renderer='string', xhr=True)
498 506 def repo_commit_comment_history_view(self):
499 507 c = self.load_default_context()
500 508
501 509 comment_history_id = self.request.matchdict['comment_history_id']
502 510 comment_history = ChangesetCommentHistory.get_or_404(comment_history_id)
503 511 is_repo_comment = comment_history.comment.repo.repo_id == self.db_repo.repo_id
504 512
505 513 if is_repo_comment:
506 514 c.comment_history = comment_history
507 515
508 516 rendered_comment = render(
509 517 'rhodecode:templates/changeset/comment_history.mako',
510 518 self._get_template_context(c)
511 519 , self.request)
512 520 return rendered_comment
513 521 else:
514 522 log.warning('No permissions for user %s to show comment_history_id: %s',
515 523 self._rhodecode_db_user, comment_history_id)
516 524 raise HTTPNotFound()
517 525
518 526 @LoginRequired()
519 527 @NotAnonymous()
520 528 @HasRepoPermissionAnyDecorator(
521 529 'repository.read', 'repository.write', 'repository.admin')
522 530 @CSRFRequired()
523 531 @view_config(
524 532 route_name='repo_commit_comment_attachment_upload', request_method='POST',
525 533 renderer='json_ext', xhr=True)
526 534 def repo_commit_comment_attachment_upload(self):
527 535 c = self.load_default_context()
528 536 upload_key = 'attachment'
529 537
530 538 file_obj = self.request.POST.get(upload_key)
531 539
532 540 if file_obj is None:
533 541 self.request.response.status = 400
534 542 return {'store_fid': None,
535 543 'access_path': None,
536 544 'error': '{} data field is missing'.format(upload_key)}
537 545
538 546 if not hasattr(file_obj, 'filename'):
539 547 self.request.response.status = 400
540 548 return {'store_fid': None,
541 549 'access_path': None,
542 550 'error': 'filename cannot be read from the data field'}
543 551
544 552 filename = file_obj.filename
545 553 file_display_name = filename
546 554
547 555 metadata = {
548 556 'user_uploaded': {'username': self._rhodecode_user.username,
549 557 'user_id': self._rhodecode_user.user_id,
550 558 'ip': self._rhodecode_user.ip_addr}}
551 559
552 560 # TODO(marcink): allow .ini configuration for allowed_extensions, and file-size
553 561 allowed_extensions = [
554 562 'gif', '.jpeg', '.jpg', '.png', '.docx', '.gz', '.log', '.pdf',
555 563 '.pptx', '.txt', '.xlsx', '.zip']
556 564 max_file_size = 10 * 1024 * 1024 # 10MB, also validated via dropzone.js
557 565
558 566 try:
559 567 storage = store_utils.get_file_storage(self.request.registry.settings)
560 568 store_uid, metadata = storage.save_file(
561 569 file_obj.file, filename, extra_metadata=metadata,
562 570 extensions=allowed_extensions, max_filesize=max_file_size)
563 571 except FileNotAllowedException:
564 572 self.request.response.status = 400
565 573 permitted_extensions = ', '.join(allowed_extensions)
566 574 error_msg = 'File `{}` is not allowed. ' \
567 575 'Only following extensions are permitted: {}'.format(
568 576 filename, permitted_extensions)
569 577 return {'store_fid': None,
570 578 'access_path': None,
571 579 'error': error_msg}
572 580 except FileOverSizeException:
573 581 self.request.response.status = 400
574 582 limit_mb = h.format_byte_size_binary(max_file_size)
575 583 return {'store_fid': None,
576 584 'access_path': None,
577 585 'error': 'File {} is exceeding allowed limit of {}.'.format(
578 586 filename, limit_mb)}
579 587
580 588 try:
581 589 entry = FileStore.create(
582 590 file_uid=store_uid, filename=metadata["filename"],
583 591 file_hash=metadata["sha256"], file_size=metadata["size"],
584 592 file_display_name=file_display_name,
585 593 file_description=u'comment attachment `{}`'.format(safe_unicode(filename)),
586 594 hidden=True, check_acl=True, user_id=self._rhodecode_user.user_id,
587 595 scope_repo_id=self.db_repo.repo_id
588 596 )
589 597 Session().add(entry)
590 598 Session().commit()
591 599 log.debug('Stored upload in DB as %s', entry)
592 600 except Exception:
593 601 log.exception('Failed to store file %s', filename)
594 602 self.request.response.status = 400
595 603 return {'store_fid': None,
596 604 'access_path': None,
597 605 'error': 'File {} failed to store in DB.'.format(filename)}
598 606
599 607 Session().commit()
600 608
601 609 return {
602 610 'store_fid': store_uid,
603 611 'access_path': h.route_path(
604 612 'download_file', fid=store_uid),
605 613 'fqn_access_path': h.route_url(
606 614 'download_file', fid=store_uid),
607 615 'repo_access_path': h.route_path(
608 616 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
609 617 'repo_fqn_access_path': h.route_url(
610 618 'repo_artifacts_get', repo_name=self.db_repo_name, uid=store_uid),
611 619 }
612 620
613 621 @LoginRequired()
614 622 @NotAnonymous()
615 623 @HasRepoPermissionAnyDecorator(
616 624 'repository.read', 'repository.write', 'repository.admin')
617 625 @CSRFRequired()
618 626 @view_config(
619 627 route_name='repo_commit_comment_delete', request_method='POST',
620 628 renderer='json_ext')
621 629 def repo_commit_comment_delete(self):
622 630 commit_id = self.request.matchdict['commit_id']
623 631 comment_id = self.request.matchdict['comment_id']
624 632
625 633 comment = ChangesetComment.get_or_404(comment_id)
626 634 if not comment:
627 635 log.debug('Comment with id:%s not found, skipping', comment_id)
628 636 # comment already deleted in another call probably
629 637 return True
630 638
631 639 if comment.immutable:
632 640 # don't allow deleting comments that are immutable
633 641 raise HTTPForbidden()
634 642
635 643 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
636 644 super_admin = h.HasPermissionAny('hg.admin')()
637 645 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
638 646 is_repo_comment = comment.repo.repo_id == self.db_repo.repo_id
639 647 comment_repo_admin = is_repo_admin and is_repo_comment
640 648
641 649 if super_admin or comment_owner or comment_repo_admin:
642 650 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
643 651 Session().commit()
644 652 return True
645 653 else:
646 654 log.warning('No permissions for user %s to delete comment_id: %s',
647 655 self._rhodecode_db_user, comment_id)
648 656 raise HTTPNotFound()
649 657
650 658 @LoginRequired()
651 659 @NotAnonymous()
652 660 @HasRepoPermissionAnyDecorator(
653 661 'repository.read', 'repository.write', 'repository.admin')
654 662 @CSRFRequired()
655 663 @view_config(
656 664 route_name='repo_commit_comment_edit', request_method='POST',
657 665 renderer='json_ext')
658 666 def repo_commit_comment_edit(self):
659 667 self.load_default_context()
660 668
661 669 comment_id = self.request.matchdict['comment_id']
662 670 comment = ChangesetComment.get_or_404(comment_id)
663 671
664 672 if comment.immutable:
665 673 # don't allow deleting comments that are immutable
666 674 raise HTTPForbidden()
667 675
668 676 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
669 677 super_admin = h.HasPermissionAny('hg.admin')()
670 678 comment_owner = (comment.author.user_id == self._rhodecode_db_user.user_id)
671 679 is_repo_comment = comment.repo.repo_id == self.db_repo.repo_id
672 680 comment_repo_admin = is_repo_admin and is_repo_comment
673 681
674 682 if super_admin or comment_owner or comment_repo_admin:
675 683 text = self.request.POST.get('text')
676 684 version = self.request.POST.get('version')
677 685 if text == comment.text:
678 686 log.warning(
679 687 'Comment(repo): '
680 688 'Trying to create new version '
681 689 'with the same comment body {}'.format(
682 690 comment_id,
683 691 )
684 692 )
685 693 raise HTTPNotFound()
686 694
687 695 if version.isdigit():
688 696 version = int(version)
689 697 else:
690 698 log.warning(
691 699 'Comment(repo): Wrong version type {} {} '
692 700 'for comment {}'.format(
693 701 version,
694 702 type(version),
695 703 comment_id,
696 704 )
697 705 )
698 706 raise HTTPNotFound()
699 707
700 708 try:
701 709 comment_history = CommentsModel().edit(
702 710 comment_id=comment_id,
703 711 text=text,
704 712 auth_user=self._rhodecode_user,
705 713 version=version,
706 714 )
707 715 except CommentVersionMismatch:
708 716 raise HTTPConflict()
709 717
710 718 if not comment_history:
711 719 raise HTTPNotFound()
712 720
713 721 commit_id = self.request.matchdict['commit_id']
714 722 commit = self.db_repo.get_commit(commit_id)
715 723 CommentsModel().trigger_commit_comment_hook(
716 724 self.db_repo, self._rhodecode_user, 'edit',
717 725 data={'comment': comment, 'commit': commit})
718 726
719 727 Session().commit()
720 728 return {
721 729 'comment_history_id': comment_history.comment_history_id,
722 730 'comment_id': comment.comment_id,
723 731 'comment_version': comment_history.version,
724 732 'comment_author_username': comment_history.author.username,
725 733 'comment_author_gravatar': h.gravatar_url(comment_history.author.email, 16),
726 734 'comment_created_on': h.age_component(comment_history.created_on,
727 735 time_is_local=True),
728 736 }
729 737 else:
730 738 log.warning('No permissions for user %s to edit comment_id: %s',
731 739 self._rhodecode_db_user, comment_id)
732 740 raise HTTPNotFound()
733 741
734 742 @LoginRequired()
735 743 @HasRepoPermissionAnyDecorator(
736 744 'repository.read', 'repository.write', 'repository.admin')
737 745 @view_config(
738 746 route_name='repo_commit_data', request_method='GET',
739 747 renderer='json_ext', xhr=True)
740 748 def repo_commit_data(self):
741 749 commit_id = self.request.matchdict['commit_id']
742 750 self.load_default_context()
743 751
744 752 try:
745 753 return self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
746 754 except CommitDoesNotExistError as e:
747 755 return EmptyCommit(message=str(e))
748 756
749 757 @LoginRequired()
750 758 @HasRepoPermissionAnyDecorator(
751 759 'repository.read', 'repository.write', 'repository.admin')
752 760 @view_config(
753 761 route_name='repo_commit_children', request_method='GET',
754 762 renderer='json_ext', xhr=True)
755 763 def repo_commit_children(self):
756 764 commit_id = self.request.matchdict['commit_id']
757 765 self.load_default_context()
758 766
759 767 try:
760 768 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
761 769 children = commit.children
762 770 except CommitDoesNotExistError:
763 771 children = []
764 772
765 773 result = {"results": children}
766 774 return result
767 775
768 776 @LoginRequired()
769 777 @HasRepoPermissionAnyDecorator(
770 778 'repository.read', 'repository.write', 'repository.admin')
771 779 @view_config(
772 780 route_name='repo_commit_parents', request_method='GET',
773 781 renderer='json_ext')
774 782 def repo_commit_parents(self):
775 783 commit_id = self.request.matchdict['commit_id']
776 784 self.load_default_context()
777 785
778 786 try:
779 787 commit = self.rhodecode_vcs_repo.get_commit(commit_id=commit_id)
780 788 parents = commit.parents
781 789 except CommitDoesNotExistError:
782 790 parents = []
783 791 result = {"results": parents}
784 792 return result
@@ -1,1794 +1,1806 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import logging
22 22 import collections
23 23
24 24 import formencode
25 25 import formencode.htmlfill
26 26 import peppercorn
27 27 from pyramid.httpexceptions import (
28 28 HTTPFound, HTTPNotFound, HTTPForbidden, HTTPBadRequest, HTTPConflict)
29 29 from pyramid.view import view_config
30 30 from pyramid.renderers import render
31 31
32 32 from rhodecode.apps._base import RepoAppView, DataGridAppView
33 33
34 34 from rhodecode.lib import helpers as h, diffs, codeblocks, channelstream
35 35 from rhodecode.lib.base import vcs_operation_context
36 36 from rhodecode.lib.diffs import load_cached_diff, cache_diff, diff_cache_exist
37 37 from rhodecode.lib.exceptions import CommentVersionMismatch
38 38 from rhodecode.lib.ext_json import json
39 39 from rhodecode.lib.auth import (
40 40 LoginRequired, HasRepoPermissionAny, HasRepoPermissionAnyDecorator,
41 41 NotAnonymous, CSRFRequired)
42 42 from rhodecode.lib.utils2 import str2bool, safe_str, safe_unicode, safe_int, aslist
43 43 from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason
44 44 from rhodecode.lib.vcs.exceptions import (
45 45 CommitDoesNotExistError, RepositoryRequirementError, EmptyRepositoryError)
46 46 from rhodecode.model.changeset_status import ChangesetStatusModel
47 47 from rhodecode.model.comment import CommentsModel
48 48 from rhodecode.model.db import (
49 49 func, or_, PullRequest, ChangesetComment, ChangesetStatus, Repository,
50 50 PullRequestReviewers)
51 51 from rhodecode.model.forms import PullRequestForm
52 52 from rhodecode.model.meta import Session
53 53 from rhodecode.model.pull_request import PullRequestModel, MergeCheck
54 54 from rhodecode.model.scm import ScmModel
55 55
56 56 log = logging.getLogger(__name__)
57 57
58 58
59 59 class RepoPullRequestsView(RepoAppView, DataGridAppView):
60 60
61 61 def load_default_context(self):
62 62 c = self._get_local_tmpl_context(include_app_defaults=True)
63 63 c.REVIEW_STATUS_APPROVED = ChangesetStatus.STATUS_APPROVED
64 64 c.REVIEW_STATUS_REJECTED = ChangesetStatus.STATUS_REJECTED
65 65 # backward compat., we use for OLD PRs a plain renderer
66 66 c.renderer = 'plain'
67 67 return c
68 68
69 69 def _get_pull_requests_list(
70 70 self, repo_name, source, filter_type, opened_by, statuses):
71 71
72 72 draw, start, limit = self._extract_chunk(self.request)
73 73 search_q, order_by, order_dir = self._extract_ordering(self.request)
74 74 _render = self.request.get_partial_renderer(
75 75 'rhodecode:templates/data_table/_dt_elements.mako')
76 76
77 77 # pagination
78 78
79 79 if filter_type == 'awaiting_review':
80 80 pull_requests = PullRequestModel().get_awaiting_review(
81 81 repo_name, search_q=search_q, source=source, opened_by=opened_by,
82 82 statuses=statuses, offset=start, length=limit,
83 83 order_by=order_by, order_dir=order_dir)
84 84 pull_requests_total_count = PullRequestModel().count_awaiting_review(
85 85 repo_name, search_q=search_q, source=source, statuses=statuses,
86 86 opened_by=opened_by)
87 87 elif filter_type == 'awaiting_my_review':
88 88 pull_requests = PullRequestModel().get_awaiting_my_review(
89 89 repo_name, search_q=search_q, source=source, opened_by=opened_by,
90 90 user_id=self._rhodecode_user.user_id, statuses=statuses,
91 91 offset=start, length=limit, order_by=order_by,
92 92 order_dir=order_dir)
93 93 pull_requests_total_count = PullRequestModel().count_awaiting_my_review(
94 94 repo_name, search_q=search_q, source=source, user_id=self._rhodecode_user.user_id,
95 95 statuses=statuses, opened_by=opened_by)
96 96 else:
97 97 pull_requests = PullRequestModel().get_all(
98 98 repo_name, search_q=search_q, source=source, opened_by=opened_by,
99 99 statuses=statuses, offset=start, length=limit,
100 100 order_by=order_by, order_dir=order_dir)
101 101 pull_requests_total_count = PullRequestModel().count_all(
102 102 repo_name, search_q=search_q, source=source, statuses=statuses,
103 103 opened_by=opened_by)
104 104
105 105 data = []
106 106 comments_model = CommentsModel()
107 107 for pr in pull_requests:
108 108 comments = comments_model.get_all_comments(
109 109 self.db_repo.repo_id, pull_request=pr)
110 110
111 111 data.append({
112 112 'name': _render('pullrequest_name',
113 113 pr.pull_request_id, pr.pull_request_state,
114 114 pr.work_in_progress, pr.target_repo.repo_name),
115 115 'name_raw': pr.pull_request_id,
116 116 'status': _render('pullrequest_status',
117 117 pr.calculated_review_status()),
118 118 'title': _render('pullrequest_title', pr.title, pr.description),
119 119 'description': h.escape(pr.description),
120 120 'updated_on': _render('pullrequest_updated_on',
121 121 h.datetime_to_time(pr.updated_on)),
122 122 'updated_on_raw': h.datetime_to_time(pr.updated_on),
123 123 'created_on': _render('pullrequest_updated_on',
124 124 h.datetime_to_time(pr.created_on)),
125 125 'created_on_raw': h.datetime_to_time(pr.created_on),
126 126 'state': pr.pull_request_state,
127 127 'author': _render('pullrequest_author',
128 128 pr.author.full_contact, ),
129 129 'author_raw': pr.author.full_name,
130 130 'comments': _render('pullrequest_comments', len(comments)),
131 131 'comments_raw': len(comments),
132 132 'closed': pr.is_closed(),
133 133 })
134 134
135 135 data = ({
136 136 'draw': draw,
137 137 'data': data,
138 138 'recordsTotal': pull_requests_total_count,
139 139 'recordsFiltered': pull_requests_total_count,
140 140 })
141 141 return data
142 142
143 143 @LoginRequired()
144 144 @HasRepoPermissionAnyDecorator(
145 145 'repository.read', 'repository.write', 'repository.admin')
146 146 @view_config(
147 147 route_name='pullrequest_show_all', request_method='GET',
148 148 renderer='rhodecode:templates/pullrequests/pullrequests.mako')
149 149 def pull_request_list(self):
150 150 c = self.load_default_context()
151 151
152 152 req_get = self.request.GET
153 153 c.source = str2bool(req_get.get('source'))
154 154 c.closed = str2bool(req_get.get('closed'))
155 155 c.my = str2bool(req_get.get('my'))
156 156 c.awaiting_review = str2bool(req_get.get('awaiting_review'))
157 157 c.awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
158 158
159 159 c.active = 'open'
160 160 if c.my:
161 161 c.active = 'my'
162 162 if c.closed:
163 163 c.active = 'closed'
164 164 if c.awaiting_review and not c.source:
165 165 c.active = 'awaiting'
166 166 if c.source and not c.awaiting_review:
167 167 c.active = 'source'
168 168 if c.awaiting_my_review:
169 169 c.active = 'awaiting_my'
170 170
171 171 return self._get_template_context(c)
172 172
173 173 @LoginRequired()
174 174 @HasRepoPermissionAnyDecorator(
175 175 'repository.read', 'repository.write', 'repository.admin')
176 176 @view_config(
177 177 route_name='pullrequest_show_all_data', request_method='GET',
178 178 renderer='json_ext', xhr=True)
179 179 def pull_request_list_data(self):
180 180 self.load_default_context()
181 181
182 182 # additional filters
183 183 req_get = self.request.GET
184 184 source = str2bool(req_get.get('source'))
185 185 closed = str2bool(req_get.get('closed'))
186 186 my = str2bool(req_get.get('my'))
187 187 awaiting_review = str2bool(req_get.get('awaiting_review'))
188 188 awaiting_my_review = str2bool(req_get.get('awaiting_my_review'))
189 189
190 190 filter_type = 'awaiting_review' if awaiting_review \
191 191 else 'awaiting_my_review' if awaiting_my_review \
192 192 else None
193 193
194 194 opened_by = None
195 195 if my:
196 196 opened_by = [self._rhodecode_user.user_id]
197 197
198 198 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
199 199 if closed:
200 200 statuses = [PullRequest.STATUS_CLOSED]
201 201
202 202 data = self._get_pull_requests_list(
203 203 repo_name=self.db_repo_name, source=source,
204 204 filter_type=filter_type, opened_by=opened_by, statuses=statuses)
205 205
206 206 return data
207 207
208 208 def _is_diff_cache_enabled(self, target_repo):
209 209 caching_enabled = self._get_general_setting(
210 210 target_repo, 'rhodecode_diff_cache')
211 211 log.debug('Diff caching enabled: %s', caching_enabled)
212 212 return caching_enabled
213 213
214 214 def _get_diffset(self, source_repo_name, source_repo,
215 215 ancestor_commit,
216 216 source_ref_id, target_ref_id,
217 217 target_commit, source_commit, diff_limit, file_limit,
218 218 fulldiff, hide_whitespace_changes, diff_context, use_ancestor=True):
219 219
220 220 if use_ancestor:
221 221 # we might want to not use it for versions
222 222 target_ref_id = ancestor_commit.raw_id
223 223
224 224 vcs_diff = PullRequestModel().get_diff(
225 225 source_repo, source_ref_id, target_ref_id,
226 226 hide_whitespace_changes, diff_context)
227 227
228 228 diff_processor = diffs.DiffProcessor(
229 229 vcs_diff, format='newdiff', diff_limit=diff_limit,
230 230 file_limit=file_limit, show_full_diff=fulldiff)
231 231
232 232 _parsed = diff_processor.prepare()
233 233
234 234 diffset = codeblocks.DiffSet(
235 235 repo_name=self.db_repo_name,
236 236 source_repo_name=source_repo_name,
237 237 source_node_getter=codeblocks.diffset_node_getter(target_commit),
238 238 target_node_getter=codeblocks.diffset_node_getter(source_commit),
239 239 )
240 240 diffset = self.path_filter.render_patchset_filtered(
241 241 diffset, _parsed, target_commit.raw_id, source_commit.raw_id)
242 242
243 243 return diffset
244 244
245 245 def _get_range_diffset(self, source_scm, source_repo,
246 246 commit1, commit2, diff_limit, file_limit,
247 247 fulldiff, hide_whitespace_changes, diff_context):
248 248 vcs_diff = source_scm.get_diff(
249 249 commit1, commit2,
250 250 ignore_whitespace=hide_whitespace_changes,
251 251 context=diff_context)
252 252
253 253 diff_processor = diffs.DiffProcessor(
254 254 vcs_diff, format='newdiff', diff_limit=diff_limit,
255 255 file_limit=file_limit, show_full_diff=fulldiff)
256 256
257 257 _parsed = diff_processor.prepare()
258 258
259 259 diffset = codeblocks.DiffSet(
260 260 repo_name=source_repo.repo_name,
261 261 source_node_getter=codeblocks.diffset_node_getter(commit1),
262 262 target_node_getter=codeblocks.diffset_node_getter(commit2))
263 263
264 264 diffset = self.path_filter.render_patchset_filtered(
265 265 diffset, _parsed, commit1.raw_id, commit2.raw_id)
266 266
267 267 return diffset
268 268
269 269 def register_comments_vars(self, c, pull_request, versions):
270 270 comments_model = CommentsModel()
271 271
272 272 # GENERAL COMMENTS with versions #
273 273 q = comments_model._all_general_comments_of_pull_request(pull_request)
274 274 q = q.order_by(ChangesetComment.comment_id.asc())
275 275 general_comments = q
276 276
277 277 # pick comments we want to render at current version
278 278 c.comment_versions = comments_model.aggregate_comments(
279 279 general_comments, versions, c.at_version_num)
280 280
281 281 # INLINE COMMENTS with versions #
282 282 q = comments_model._all_inline_comments_of_pull_request(pull_request)
283 283 q = q.order_by(ChangesetComment.comment_id.asc())
284 284 inline_comments = q
285 285
286 286 c.inline_versions = comments_model.aggregate_comments(
287 287 inline_comments, versions, c.at_version_num, inline=True)
288 288
289 289 # Comments inline+general
290 290 if c.at_version:
291 291 c.inline_comments_flat = c.inline_versions[c.at_version_num]['display']
292 292 c.comments = c.comment_versions[c.at_version_num]['display']
293 293 else:
294 294 c.inline_comments_flat = c.inline_versions[c.at_version_num]['until']
295 295 c.comments = c.comment_versions[c.at_version_num]['until']
296 296
297 297 return general_comments, inline_comments
298 298
299 299 @LoginRequired()
300 300 @HasRepoPermissionAnyDecorator(
301 301 'repository.read', 'repository.write', 'repository.admin')
302 302 @view_config(
303 303 route_name='pullrequest_show', request_method='GET',
304 304 renderer='rhodecode:templates/pullrequests/pullrequest_show.mako')
305 305 def pull_request_show(self):
306 306 _ = self.request.translate
307 307 c = self.load_default_context()
308 308
309 309 pull_request = PullRequest.get_or_404(
310 310 self.request.matchdict['pull_request_id'])
311 311 pull_request_id = pull_request.pull_request_id
312 312
313 313 c.state_progressing = pull_request.is_state_changing()
314 c.pr_broadcast_channel = '/repo${}$/pr/{}'.format(
315 pull_request.target_repo.repo_name, pull_request.pull_request_id)
314 c.pr_broadcast_channel = channelstream.pr_channel(pull_request)
316 315
317 316 _new_state = {
318 317 'created': PullRequest.STATE_CREATED,
319 318 }.get(self.request.GET.get('force_state'))
320 319
321 320 if c.is_super_admin and _new_state:
322 321 with pull_request.set_state(PullRequest.STATE_UPDATING, final_state=_new_state):
323 322 h.flash(
324 323 _('Pull Request state was force changed to `{}`').format(_new_state),
325 324 category='success')
326 325 Session().commit()
327 326
328 327 raise HTTPFound(h.route_path(
329 328 'pullrequest_show', repo_name=self.db_repo_name,
330 329 pull_request_id=pull_request_id))
331 330
332 331 version = self.request.GET.get('version')
333 332 from_version = self.request.GET.get('from_version') or version
334 333 merge_checks = self.request.GET.get('merge_checks')
335 334 c.fulldiff = str2bool(self.request.GET.get('fulldiff'))
336 335 force_refresh = str2bool(self.request.GET.get('force_refresh'))
337 336 c.range_diff_on = self.request.GET.get('range-diff') == "1"
338 337
339 338 # fetch global flags of ignore ws or context lines
340 339 diff_context = diffs.get_diff_context(self.request)
341 340 hide_whitespace_changes = diffs.get_diff_whitespace_flag(self.request)
342 341
343 342 (pull_request_latest,
344 343 pull_request_at_ver,
345 344 pull_request_display_obj,
346 345 at_version) = PullRequestModel().get_pr_version(
347 346 pull_request_id, version=version)
348 347
349 348 pr_closed = pull_request_latest.is_closed()
350 349
351 350 if pr_closed and (version or from_version):
352 351 # not allow to browse versions for closed PR
353 352 raise HTTPFound(h.route_path(
354 353 'pullrequest_show', repo_name=self.db_repo_name,
355 354 pull_request_id=pull_request_id))
356 355
357 356 versions = pull_request_display_obj.versions()
358 357 # used to store per-commit range diffs
359 358 c.changes = collections.OrderedDict()
360 359
361 360 c.at_version = at_version
362 361 c.at_version_num = (at_version
363 362 if at_version and at_version != PullRequest.LATEST_VER
364 363 else None)
365 364
366 365 c.at_version_index = ChangesetComment.get_index_from_version(
367 366 c.at_version_num, versions)
368 367
369 368 (prev_pull_request_latest,
370 369 prev_pull_request_at_ver,
371 370 prev_pull_request_display_obj,
372 371 prev_at_version) = PullRequestModel().get_pr_version(
373 372 pull_request_id, version=from_version)
374 373
375 374 c.from_version = prev_at_version
376 375 c.from_version_num = (prev_at_version
377 376 if prev_at_version and prev_at_version != PullRequest.LATEST_VER
378 377 else None)
379 378 c.from_version_index = ChangesetComment.get_index_from_version(
380 379 c.from_version_num, versions)
381 380
382 381 # define if we're in COMPARE mode or VIEW at version mode
383 382 compare = at_version != prev_at_version
384 383
385 384 # pull_requests repo_name we opened it against
386 385 # ie. target_repo must match
387 386 if self.db_repo_name != pull_request_at_ver.target_repo.repo_name:
388 387 log.warning('Mismatch between the current repo: %s, and target %s',
389 388 self.db_repo_name, pull_request_at_ver.target_repo.repo_name)
390 389 raise HTTPNotFound()
391 390
392 391 c.shadow_clone_url = PullRequestModel().get_shadow_clone_url(pull_request_at_ver)
393 392
394 393 c.pull_request = pull_request_display_obj
395 394 c.renderer = pull_request_at_ver.description_renderer or c.renderer
396 395 c.pull_request_latest = pull_request_latest
397 396
398 397 # inject latest version
399 398 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
400 399 c.versions = versions + [latest_ver]
401 400
402 401 if compare or (at_version and not at_version == PullRequest.LATEST_VER):
403 402 c.allowed_to_change_status = False
404 403 c.allowed_to_update = False
405 404 c.allowed_to_merge = False
406 405 c.allowed_to_delete = False
407 406 c.allowed_to_comment = False
408 407 c.allowed_to_close = False
409 408 else:
410 409 can_change_status = PullRequestModel().check_user_change_status(
411 410 pull_request_at_ver, self._rhodecode_user)
412 411 c.allowed_to_change_status = can_change_status and not pr_closed
413 412
414 413 c.allowed_to_update = PullRequestModel().check_user_update(
415 414 pull_request_latest, self._rhodecode_user) and not pr_closed
416 415 c.allowed_to_merge = PullRequestModel().check_user_merge(
417 416 pull_request_latest, self._rhodecode_user) and not pr_closed
418 417 c.allowed_to_delete = PullRequestModel().check_user_delete(
419 418 pull_request_latest, self._rhodecode_user) and not pr_closed
420 419 c.allowed_to_comment = not pr_closed
421 420 c.allowed_to_close = c.allowed_to_merge and not pr_closed
422 421
423 422 c.forbid_adding_reviewers = False
424 423 c.forbid_author_to_review = False
425 424 c.forbid_commit_author_to_review = False
426 425
427 426 if pull_request_latest.reviewer_data and \
428 427 'rules' in pull_request_latest.reviewer_data:
429 428 rules = pull_request_latest.reviewer_data['rules'] or {}
430 429 try:
431 430 c.forbid_adding_reviewers = rules.get('forbid_adding_reviewers')
432 431 c.forbid_author_to_review = rules.get('forbid_author_to_review')
433 432 c.forbid_commit_author_to_review = rules.get('forbid_commit_author_to_review')
434 433 except Exception:
435 434 pass
436 435
437 436 # check merge capabilities
438 437 _merge_check = MergeCheck.validate(
439 438 pull_request_latest, auth_user=self._rhodecode_user,
440 439 translator=self.request.translate,
441 440 force_shadow_repo_refresh=force_refresh)
442 441
443 442 c.pr_merge_errors = _merge_check.error_details
444 443 c.pr_merge_possible = not _merge_check.failed
445 444 c.pr_merge_message = _merge_check.merge_msg
446 445 c.pr_merge_source_commit = _merge_check.source_commit
447 446 c.pr_merge_target_commit = _merge_check.target_commit
448 447
449 448 c.pr_merge_info = MergeCheck.get_merge_conditions(
450 449 pull_request_latest, translator=self.request.translate)
451 450
452 451 c.pull_request_review_status = _merge_check.review_status
453 452 if merge_checks:
454 453 self.request.override_renderer = \
455 454 'rhodecode:templates/pullrequests/pullrequest_merge_checks.mako'
456 455 return self._get_template_context(c)
457 456
458 457 c.allowed_reviewers = [obj.user_id for obj in pull_request.reviewers if obj.user]
459 458 c.reviewers_count = pull_request.reviewers_count
460 459 c.observers_count = pull_request.observers_count
461 460
462 461 # reviewers and statuses
463 462 c.pull_request_default_reviewers_data_json = json.dumps(pull_request.reviewer_data)
464 463 c.pull_request_set_reviewers_data_json = collections.OrderedDict({'reviewers': []})
465 464 c.pull_request_set_observers_data_json = collections.OrderedDict({'observers': []})
466 465
467 466 for review_obj, member, reasons, mandatory, status in pull_request_at_ver.reviewers_statuses():
468 467 member_reviewer = h.reviewer_as_json(
469 468 member, reasons=reasons, mandatory=mandatory,
470 469 role=review_obj.role,
471 470 user_group=review_obj.rule_user_group_data()
472 471 )
473 472
474 473 current_review_status = status[0][1].status if status else ChangesetStatus.STATUS_NOT_REVIEWED
475 474 member_reviewer['review_status'] = current_review_status
476 475 member_reviewer['review_status_label'] = h.commit_status_lbl(current_review_status)
477 476 member_reviewer['allowed_to_update'] = c.allowed_to_update
478 477 c.pull_request_set_reviewers_data_json['reviewers'].append(member_reviewer)
479 478
480 479 c.pull_request_set_reviewers_data_json = json.dumps(c.pull_request_set_reviewers_data_json)
481 480
482 481 for observer_obj, member in pull_request_at_ver.observers():
483 482 member_observer = h.reviewer_as_json(
484 483 member, reasons=[], mandatory=False,
485 484 role=observer_obj.role,
486 485 user_group=observer_obj.rule_user_group_data()
487 486 )
488 487 member_observer['allowed_to_update'] = c.allowed_to_update
489 488 c.pull_request_set_observers_data_json['observers'].append(member_observer)
490 489
491 490 c.pull_request_set_observers_data_json = json.dumps(c.pull_request_set_observers_data_json)
492 491
493 492 general_comments, inline_comments = \
494 493 self.register_comments_vars(c, pull_request_latest, versions)
495 494
496 495 # TODOs
497 496 c.unresolved_comments = CommentsModel() \
498 497 .get_pull_request_unresolved_todos(pull_request_latest)
499 498 c.resolved_comments = CommentsModel() \
500 499 .get_pull_request_resolved_todos(pull_request_latest)
501 500
502 501 # if we use version, then do not show later comments
503 502 # than current version
504 503 display_inline_comments = collections.defaultdict(
505 504 lambda: collections.defaultdict(list))
506 505 for co in inline_comments:
507 506 if c.at_version_num:
508 507 # pick comments that are at least UPTO given version, so we
509 508 # don't render comments for higher version
510 509 should_render = co.pull_request_version_id and \
511 510 co.pull_request_version_id <= c.at_version_num
512 511 else:
513 512 # showing all, for 'latest'
514 513 should_render = True
515 514
516 515 if should_render:
517 516 display_inline_comments[co.f_path][co.line_no].append(co)
518 517
519 518 # load diff data into template context, if we use compare mode then
520 519 # diff is calculated based on changes between versions of PR
521 520
522 521 source_repo = pull_request_at_ver.source_repo
523 522 source_ref_id = pull_request_at_ver.source_ref_parts.commit_id
524 523
525 524 target_repo = pull_request_at_ver.target_repo
526 525 target_ref_id = pull_request_at_ver.target_ref_parts.commit_id
527 526
528 527 if compare:
529 528 # in compare switch the diff base to latest commit from prev version
530 529 target_ref_id = prev_pull_request_display_obj.revisions[0]
531 530
532 531 # despite opening commits for bookmarks/branches/tags, we always
533 532 # convert this to rev to prevent changes after bookmark or branch change
534 533 c.source_ref_type = 'rev'
535 534 c.source_ref = source_ref_id
536 535
537 536 c.target_ref_type = 'rev'
538 537 c.target_ref = target_ref_id
539 538
540 539 c.source_repo = source_repo
541 540 c.target_repo = target_repo
542 541
543 542 c.commit_ranges = []
544 543 source_commit = EmptyCommit()
545 544 target_commit = EmptyCommit()
546 545 c.missing_requirements = False
547 546
548 547 source_scm = source_repo.scm_instance()
549 548 target_scm = target_repo.scm_instance()
550 549
551 550 shadow_scm = None
552 551 try:
553 552 shadow_scm = pull_request_latest.get_shadow_repo()
554 553 except Exception:
555 554 log.debug('Failed to get shadow repo', exc_info=True)
556 555 # try first the existing source_repo, and then shadow
557 556 # repo if we can obtain one
558 557 commits_source_repo = source_scm
559 558 if shadow_scm:
560 559 commits_source_repo = shadow_scm
561 560
562 561 c.commits_source_repo = commits_source_repo
563 562 c.ancestor = None # set it to None, to hide it from PR view
564 563
565 564 # empty version means latest, so we keep this to prevent
566 565 # double caching
567 566 version_normalized = version or PullRequest.LATEST_VER
568 567 from_version_normalized = from_version or PullRequest.LATEST_VER
569 568
570 569 cache_path = self.rhodecode_vcs_repo.get_create_shadow_cache_pr_path(target_repo)
571 570 cache_file_path = diff_cache_exist(
572 571 cache_path, 'pull_request', pull_request_id, version_normalized,
573 572 from_version_normalized, source_ref_id, target_ref_id,
574 573 hide_whitespace_changes, diff_context, c.fulldiff)
575 574
576 575 caching_enabled = self._is_diff_cache_enabled(c.target_repo)
577 576 force_recache = self.get_recache_flag()
578 577
579 578 cached_diff = None
580 579 if caching_enabled:
581 580 cached_diff = load_cached_diff(cache_file_path)
582 581
583 582 has_proper_commit_cache = (
584 583 cached_diff and cached_diff.get('commits')
585 584 and len(cached_diff.get('commits', [])) == 5
586 585 and cached_diff.get('commits')[0]
587 586 and cached_diff.get('commits')[3])
588 587
589 588 if not force_recache and not c.range_diff_on and has_proper_commit_cache:
590 589 diff_commit_cache = \
591 590 (ancestor_commit, commit_cache, missing_requirements,
592 591 source_commit, target_commit) = cached_diff['commits']
593 592 else:
594 593 # NOTE(marcink): we reach potentially unreachable errors when a PR has
595 594 # merge errors resulting in potentially hidden commits in the shadow repo.
596 595 maybe_unreachable = _merge_check.MERGE_CHECK in _merge_check.error_details \
597 596 and _merge_check.merge_response
598 597 maybe_unreachable = maybe_unreachable \
599 598 and _merge_check.merge_response.metadata.get('unresolved_files')
600 599 log.debug("Using unreachable commits due to MERGE_CHECK in merge simulation")
601 600 diff_commit_cache = \
602 601 (ancestor_commit, commit_cache, missing_requirements,
603 602 source_commit, target_commit) = self.get_commits(
604 603 commits_source_repo,
605 604 pull_request_at_ver,
606 605 source_commit,
607 606 source_ref_id,
608 607 source_scm,
609 608 target_commit,
610 609 target_ref_id,
611 610 target_scm,
612 611 maybe_unreachable=maybe_unreachable)
613 612
614 613 # register our commit range
615 614 for comm in commit_cache.values():
616 615 c.commit_ranges.append(comm)
617 616
618 617 c.missing_requirements = missing_requirements
619 618 c.ancestor_commit = ancestor_commit
620 619 c.statuses = source_repo.statuses(
621 620 [x.raw_id for x in c.commit_ranges])
622 621
623 622 # auto collapse if we have more than limit
624 623 collapse_limit = diffs.DiffProcessor._collapse_commits_over
625 624 c.collapse_all_commits = len(c.commit_ranges) > collapse_limit
626 625 c.compare_mode = compare
627 626
628 627 # diff_limit is the old behavior, will cut off the whole diff
629 628 # if the limit is applied otherwise will just hide the
630 629 # big files from the front-end
631 630 diff_limit = c.visual.cut_off_limit_diff
632 631 file_limit = c.visual.cut_off_limit_file
633 632
634 633 c.missing_commits = False
635 634 if (c.missing_requirements
636 635 or isinstance(source_commit, EmptyCommit)
637 636 or source_commit == target_commit):
638 637
639 638 c.missing_commits = True
640 639 else:
641 640 c.inline_comments = display_inline_comments
642 641
643 642 use_ancestor = True
644 643 if from_version_normalized != version_normalized:
645 644 use_ancestor = False
646 645
647 646 has_proper_diff_cache = cached_diff and cached_diff.get('commits')
648 647 if not force_recache and has_proper_diff_cache:
649 648 c.diffset = cached_diff['diff']
650 649 else:
651 650 try:
652 651 c.diffset = self._get_diffset(
653 652 c.source_repo.repo_name, commits_source_repo,
654 653 c.ancestor_commit,
655 654 source_ref_id, target_ref_id,
656 655 target_commit, source_commit,
657 656 diff_limit, file_limit, c.fulldiff,
658 657 hide_whitespace_changes, diff_context,
659 658 use_ancestor=use_ancestor
660 659 )
661 660
662 661 # save cached diff
663 662 if caching_enabled:
664 663 cache_diff(cache_file_path, c.diffset, diff_commit_cache)
665 664 except CommitDoesNotExistError:
666 665 log.exception('Failed to generate diffset')
667 666 c.missing_commits = True
668 667
669 668 if not c.missing_commits:
670 669
671 670 c.limited_diff = c.diffset.limited_diff
672 671
673 672 # calculate removed files that are bound to comments
674 673 comment_deleted_files = [
675 674 fname for fname in display_inline_comments
676 675 if fname not in c.diffset.file_stats]
677 676
678 677 c.deleted_files_comments = collections.defaultdict(dict)
679 678 for fname, per_line_comments in display_inline_comments.items():
680 679 if fname in comment_deleted_files:
681 680 c.deleted_files_comments[fname]['stats'] = 0
682 681 c.deleted_files_comments[fname]['comments'] = list()
683 682 for lno, comments in per_line_comments.items():
684 683 c.deleted_files_comments[fname]['comments'].extend(comments)
685 684
686 685 # maybe calculate the range diff
687 686 if c.range_diff_on:
688 687 # TODO(marcink): set whitespace/context
689 688 context_lcl = 3
690 689 ign_whitespace_lcl = False
691 690
692 691 for commit in c.commit_ranges:
693 692 commit2 = commit
694 693 commit1 = commit.first_parent
695 694
696 695 range_diff_cache_file_path = diff_cache_exist(
697 696 cache_path, 'diff', commit.raw_id,
698 697 ign_whitespace_lcl, context_lcl, c.fulldiff)
699 698
700 699 cached_diff = None
701 700 if caching_enabled:
702 701 cached_diff = load_cached_diff(range_diff_cache_file_path)
703 702
704 703 has_proper_diff_cache = cached_diff and cached_diff.get('diff')
705 704 if not force_recache and has_proper_diff_cache:
706 705 diffset = cached_diff['diff']
707 706 else:
708 707 diffset = self._get_range_diffset(
709 708 commits_source_repo, source_repo,
710 709 commit1, commit2, diff_limit, file_limit,
711 710 c.fulldiff, ign_whitespace_lcl, context_lcl
712 711 )
713 712
714 713 # save cached diff
715 714 if caching_enabled:
716 715 cache_diff(range_diff_cache_file_path, diffset, None)
717 716
718 717 c.changes[commit.raw_id] = diffset
719 718
720 719 # this is a hack to properly display links, when creating PR, the
721 720 # compare view and others uses different notation, and
722 721 # compare_commits.mako renders links based on the target_repo.
723 722 # We need to swap that here to generate it properly on the html side
724 723 c.target_repo = c.source_repo
725 724
726 725 c.commit_statuses = ChangesetStatus.STATUSES
727 726
728 727 c.show_version_changes = not pr_closed
729 728 if c.show_version_changes:
730 729 cur_obj = pull_request_at_ver
731 730 prev_obj = prev_pull_request_at_ver
732 731
733 732 old_commit_ids = prev_obj.revisions
734 733 new_commit_ids = cur_obj.revisions
735 734 commit_changes = PullRequestModel()._calculate_commit_id_changes(
736 735 old_commit_ids, new_commit_ids)
737 736 c.commit_changes_summary = commit_changes
738 737
739 738 # calculate the diff for commits between versions
740 739 c.commit_changes = []
741 740
742 741 def mark(cs, fw):
743 742 return list(h.itertools.izip_longest([], cs, fillvalue=fw))
744 743
745 744 for c_type, raw_id in mark(commit_changes.added, 'a') \
746 745 + mark(commit_changes.removed, 'r') \
747 746 + mark(commit_changes.common, 'c'):
748 747
749 748 if raw_id in commit_cache:
750 749 commit = commit_cache[raw_id]
751 750 else:
752 751 try:
753 752 commit = commits_source_repo.get_commit(raw_id)
754 753 except CommitDoesNotExistError:
755 754 # in case we fail extracting still use "dummy" commit
756 755 # for display in commit diff
757 756 commit = h.AttributeDict(
758 757 {'raw_id': raw_id,
759 758 'message': 'EMPTY or MISSING COMMIT'})
760 759 c.commit_changes.append([c_type, commit])
761 760
762 761 # current user review statuses for each version
763 762 c.review_versions = {}
764 763 if self._rhodecode_user.user_id in c.allowed_reviewers:
765 764 for co in general_comments:
766 765 if co.author.user_id == self._rhodecode_user.user_id:
767 766 status = co.status_change
768 767 if status:
769 768 _ver_pr = status[0].comment.pull_request_version_id
770 769 c.review_versions[_ver_pr] = status[0]
771 770
772 771 return self._get_template_context(c)
773 772
774 773 def get_commits(
775 774 self, commits_source_repo, pull_request_at_ver, source_commit,
776 775 source_ref_id, source_scm, target_commit, target_ref_id, target_scm,
777 776 maybe_unreachable=False):
778 777
779 778 commit_cache = collections.OrderedDict()
780 779 missing_requirements = False
781 780
782 781 try:
783 782 pre_load = ["author", "date", "message", "branch", "parents"]
784 783
785 784 pull_request_commits = pull_request_at_ver.revisions
786 785 log.debug('Loading %s commits from %s',
787 786 len(pull_request_commits), commits_source_repo)
788 787
789 788 for rev in pull_request_commits:
790 789 comm = commits_source_repo.get_commit(commit_id=rev, pre_load=pre_load,
791 790 maybe_unreachable=maybe_unreachable)
792 791 commit_cache[comm.raw_id] = comm
793 792
794 793 # Order here matters, we first need to get target, and then
795 794 # the source
796 795 target_commit = commits_source_repo.get_commit(
797 796 commit_id=safe_str(target_ref_id))
798 797
799 798 source_commit = commits_source_repo.get_commit(
800 799 commit_id=safe_str(source_ref_id), maybe_unreachable=True)
801 800 except CommitDoesNotExistError:
802 801 log.warning('Failed to get commit from `{}` repo'.format(
803 802 commits_source_repo), exc_info=True)
804 803 except RepositoryRequirementError:
805 804 log.warning('Failed to get all required data from repo', exc_info=True)
806 805 missing_requirements = True
807 806
808 807 pr_ancestor_id = pull_request_at_ver.common_ancestor_id
809 808
810 809 try:
811 810 ancestor_commit = source_scm.get_commit(pr_ancestor_id)
812 811 except Exception:
813 812 ancestor_commit = None
814 813
815 814 return ancestor_commit, commit_cache, missing_requirements, source_commit, target_commit
816 815
817 816 def assure_not_empty_repo(self):
818 817 _ = self.request.translate
819 818
820 819 try:
821 820 self.db_repo.scm_instance().get_commit()
822 821 except EmptyRepositoryError:
823 822 h.flash(h.literal(_('There are no commits yet')),
824 823 category='warning')
825 824 raise HTTPFound(
826 825 h.route_path('repo_summary', repo_name=self.db_repo.repo_name))
827 826
828 827 @LoginRequired()
829 828 @NotAnonymous()
830 829 @HasRepoPermissionAnyDecorator(
831 830 'repository.read', 'repository.write', 'repository.admin')
832 831 @view_config(
833 832 route_name='pullrequest_new', request_method='GET',
834 833 renderer='rhodecode:templates/pullrequests/pullrequest.mako')
835 834 def pull_request_new(self):
836 835 _ = self.request.translate
837 836 c = self.load_default_context()
838 837
839 838 self.assure_not_empty_repo()
840 839 source_repo = self.db_repo
841 840
842 841 commit_id = self.request.GET.get('commit')
843 842 branch_ref = self.request.GET.get('branch')
844 843 bookmark_ref = self.request.GET.get('bookmark')
845 844
846 845 try:
847 846 source_repo_data = PullRequestModel().generate_repo_data(
848 847 source_repo, commit_id=commit_id,
849 848 branch=branch_ref, bookmark=bookmark_ref,
850 849 translator=self.request.translate)
851 850 except CommitDoesNotExistError as e:
852 851 log.exception(e)
853 852 h.flash(_('Commit does not exist'), 'error')
854 853 raise HTTPFound(
855 854 h.route_path('pullrequest_new', repo_name=source_repo.repo_name))
856 855
857 856 default_target_repo = source_repo
858 857
859 858 if source_repo.parent and c.has_origin_repo_read_perm:
860 859 parent_vcs_obj = source_repo.parent.scm_instance()
861 860 if parent_vcs_obj and not parent_vcs_obj.is_empty():
862 861 # change default if we have a parent repo
863 862 default_target_repo = source_repo.parent
864 863
865 864 target_repo_data = PullRequestModel().generate_repo_data(
866 865 default_target_repo, translator=self.request.translate)
867 866
868 867 selected_source_ref = source_repo_data['refs']['selected_ref']
869 868 title_source_ref = ''
870 869 if selected_source_ref:
871 870 title_source_ref = selected_source_ref.split(':', 2)[1]
872 871 c.default_title = PullRequestModel().generate_pullrequest_title(
873 872 source=source_repo.repo_name,
874 873 source_ref=title_source_ref,
875 874 target=default_target_repo.repo_name
876 875 )
877 876
878 877 c.default_repo_data = {
879 878 'source_repo_name': source_repo.repo_name,
880 879 'source_refs_json': json.dumps(source_repo_data),
881 880 'target_repo_name': default_target_repo.repo_name,
882 881 'target_refs_json': json.dumps(target_repo_data),
883 882 }
884 883 c.default_source_ref = selected_source_ref
885 884
886 885 return self._get_template_context(c)
887 886
888 887 @LoginRequired()
889 888 @NotAnonymous()
890 889 @HasRepoPermissionAnyDecorator(
891 890 'repository.read', 'repository.write', 'repository.admin')
892 891 @view_config(
893 892 route_name='pullrequest_repo_refs', request_method='GET',
894 893 renderer='json_ext', xhr=True)
895 894 def pull_request_repo_refs(self):
896 895 self.load_default_context()
897 896 target_repo_name = self.request.matchdict['target_repo_name']
898 897 repo = Repository.get_by_repo_name(target_repo_name)
899 898 if not repo:
900 899 raise HTTPNotFound()
901 900
902 901 target_perm = HasRepoPermissionAny(
903 902 'repository.read', 'repository.write', 'repository.admin')(
904 903 target_repo_name)
905 904 if not target_perm:
906 905 raise HTTPNotFound()
907 906
908 907 return PullRequestModel().generate_repo_data(
909 908 repo, translator=self.request.translate)
910 909
911 910 @LoginRequired()
912 911 @NotAnonymous()
913 912 @HasRepoPermissionAnyDecorator(
914 913 'repository.read', 'repository.write', 'repository.admin')
915 914 @view_config(
916 915 route_name='pullrequest_repo_targets', request_method='GET',
917 916 renderer='json_ext', xhr=True)
918 917 def pullrequest_repo_targets(self):
919 918 _ = self.request.translate
920 919 filter_query = self.request.GET.get('query')
921 920
922 921 # get the parents
923 922 parent_target_repos = []
924 923 if self.db_repo.parent:
925 924 parents_query = Repository.query() \
926 925 .order_by(func.length(Repository.repo_name)) \
927 926 .filter(Repository.fork_id == self.db_repo.parent.repo_id)
928 927
929 928 if filter_query:
930 929 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
931 930 parents_query = parents_query.filter(
932 931 Repository.repo_name.ilike(ilike_expression))
933 932 parents = parents_query.limit(20).all()
934 933
935 934 for parent in parents:
936 935 parent_vcs_obj = parent.scm_instance()
937 936 if parent_vcs_obj and not parent_vcs_obj.is_empty():
938 937 parent_target_repos.append(parent)
939 938
940 939 # get other forks, and repo itself
941 940 query = Repository.query() \
942 941 .order_by(func.length(Repository.repo_name)) \
943 942 .filter(
944 943 or_(Repository.repo_id == self.db_repo.repo_id, # repo itself
945 944 Repository.fork_id == self.db_repo.repo_id) # forks of this repo
946 945 ) \
947 946 .filter(~Repository.repo_id.in_([x.repo_id for x in parent_target_repos]))
948 947
949 948 if filter_query:
950 949 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
951 950 query = query.filter(Repository.repo_name.ilike(ilike_expression))
952 951
953 952 limit = max(20 - len(parent_target_repos), 5) # not less then 5
954 953 target_repos = query.limit(limit).all()
955 954
956 955 all_target_repos = target_repos + parent_target_repos
957 956
958 957 repos = []
959 958 # This checks permissions to the repositories
960 959 for obj in ScmModel().get_repos(all_target_repos):
961 960 repos.append({
962 961 'id': obj['name'],
963 962 'text': obj['name'],
964 963 'type': 'repo',
965 964 'repo_id': obj['dbrepo']['repo_id'],
966 965 'repo_type': obj['dbrepo']['repo_type'],
967 966 'private': obj['dbrepo']['private'],
968 967
969 968 })
970 969
971 970 data = {
972 971 'more': False,
973 972 'results': [{
974 973 'text': _('Repositories'),
975 974 'children': repos
976 975 }] if repos else []
977 976 }
978 977 return data
979 978
980 979 def _get_existing_ids(self, post_data):
981 980 return filter(lambda e: e, map(safe_int, aslist(post_data.get('comments'), ',')))
982 981
983 982 @LoginRequired()
984 983 @NotAnonymous()
985 984 @HasRepoPermissionAnyDecorator(
986 985 'repository.read', 'repository.write', 'repository.admin')
987 986 @view_config(
988 987 route_name='pullrequest_comments', request_method='POST',
989 988 renderer='string_html', xhr=True)
990 989 def pullrequest_comments(self):
991 990 self.load_default_context()
992 991
993 992 pull_request = PullRequest.get_or_404(
994 993 self.request.matchdict['pull_request_id'])
995 994 pull_request_id = pull_request.pull_request_id
996 995 version = self.request.GET.get('version')
997 996
998 997 _render = self.request.get_partial_renderer(
999 998 'rhodecode:templates/base/sidebar.mako')
1000 999 c = _render.get_call_context()
1001 1000
1002 1001 (pull_request_latest,
1003 1002 pull_request_at_ver,
1004 1003 pull_request_display_obj,
1005 1004 at_version) = PullRequestModel().get_pr_version(
1006 1005 pull_request_id, version=version)
1007 1006 versions = pull_request_display_obj.versions()
1008 1007 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
1009 1008 c.versions = versions + [latest_ver]
1010 1009
1011 1010 c.at_version = at_version
1012 1011 c.at_version_num = (at_version
1013 1012 if at_version and at_version != PullRequest.LATEST_VER
1014 1013 else None)
1015 1014
1016 1015 self.register_comments_vars(c, pull_request_latest, versions)
1017 1016 all_comments = c.inline_comments_flat + c.comments
1018 1017
1019 1018 existing_ids = self._get_existing_ids(self.request.POST)
1020 1019 return _render('comments_table', all_comments, len(all_comments),
1021 1020 existing_ids=existing_ids)
1022 1021
1023 1022 @LoginRequired()
1024 1023 @NotAnonymous()
1025 1024 @HasRepoPermissionAnyDecorator(
1026 1025 'repository.read', 'repository.write', 'repository.admin')
1027 1026 @view_config(
1028 1027 route_name='pullrequest_todos', request_method='POST',
1029 1028 renderer='string_html', xhr=True)
1030 1029 def pullrequest_todos(self):
1031 1030 self.load_default_context()
1032 1031
1033 1032 pull_request = PullRequest.get_or_404(
1034 1033 self.request.matchdict['pull_request_id'])
1035 1034 pull_request_id = pull_request.pull_request_id
1036 1035 version = self.request.GET.get('version')
1037 1036
1038 1037 _render = self.request.get_partial_renderer(
1039 1038 'rhodecode:templates/base/sidebar.mako')
1040 1039 c = _render.get_call_context()
1041 1040 (pull_request_latest,
1042 1041 pull_request_at_ver,
1043 1042 pull_request_display_obj,
1044 1043 at_version) = PullRequestModel().get_pr_version(
1045 1044 pull_request_id, version=version)
1046 1045 versions = pull_request_display_obj.versions()
1047 1046 latest_ver = PullRequest.get_pr_display_object(pull_request_latest, pull_request_latest)
1048 1047 c.versions = versions + [latest_ver]
1049 1048
1050 1049 c.at_version = at_version
1051 1050 c.at_version_num = (at_version
1052 1051 if at_version and at_version != PullRequest.LATEST_VER
1053 1052 else None)
1054 1053
1055 1054 c.unresolved_comments = CommentsModel() \
1056 1055 .get_pull_request_unresolved_todos(pull_request)
1057 1056 c.resolved_comments = CommentsModel() \
1058 1057 .get_pull_request_resolved_todos(pull_request)
1059 1058
1060 1059 all_comments = c.unresolved_comments + c.resolved_comments
1061 1060 existing_ids = self._get_existing_ids(self.request.POST)
1062 1061 return _render('comments_table', all_comments, len(c.unresolved_comments),
1063 1062 todo_comments=True, existing_ids=existing_ids)
1064 1063
1065 1064 @LoginRequired()
1066 1065 @NotAnonymous()
1067 1066 @HasRepoPermissionAnyDecorator(
1068 1067 'repository.read', 'repository.write', 'repository.admin')
1069 1068 @CSRFRequired()
1070 1069 @view_config(
1071 1070 route_name='pullrequest_create', request_method='POST',
1072 1071 renderer=None)
1073 1072 def pull_request_create(self):
1074 1073 _ = self.request.translate
1075 1074 self.assure_not_empty_repo()
1076 1075 self.load_default_context()
1077 1076
1078 1077 controls = peppercorn.parse(self.request.POST.items())
1079 1078
1080 1079 try:
1081 1080 form = PullRequestForm(
1082 1081 self.request.translate, self.db_repo.repo_id)()
1083 1082 _form = form.to_python(controls)
1084 1083 except formencode.Invalid as errors:
1085 1084 if errors.error_dict.get('revisions'):
1086 1085 msg = 'Revisions: %s' % errors.error_dict['revisions']
1087 1086 elif errors.error_dict.get('pullrequest_title'):
1088 1087 msg = errors.error_dict.get('pullrequest_title')
1089 1088 else:
1090 1089 msg = _('Error creating pull request: {}').format(errors)
1091 1090 log.exception(msg)
1092 1091 h.flash(msg, 'error')
1093 1092
1094 1093 # would rather just go back to form ...
1095 1094 raise HTTPFound(
1096 1095 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
1097 1096
1098 1097 source_repo = _form['source_repo']
1099 1098 source_ref = _form['source_ref']
1100 1099 target_repo = _form['target_repo']
1101 1100 target_ref = _form['target_ref']
1102 1101 commit_ids = _form['revisions'][::-1]
1103 1102 common_ancestor_id = _form['common_ancestor']
1104 1103
1105 1104 # find the ancestor for this pr
1106 1105 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
1107 1106 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
1108 1107
1109 1108 if not (source_db_repo or target_db_repo):
1110 1109 h.flash(_('source_repo or target repo not found'), category='error')
1111 1110 raise HTTPFound(
1112 1111 h.route_path('pullrequest_new', repo_name=self.db_repo_name))
1113 1112
1114 1113 # re-check permissions again here
1115 1114 # source_repo we must have read permissions
1116 1115
1117 1116 source_perm = HasRepoPermissionAny(
1118 1117 'repository.read', 'repository.write', 'repository.admin')(
1119 1118 source_db_repo.repo_name)
1120 1119 if not source_perm:
1121 1120 msg = _('Not Enough permissions to source repo `{}`.'.format(
1122 1121 source_db_repo.repo_name))
1123 1122 h.flash(msg, category='error')
1124 1123 # copy the args back to redirect
1125 1124 org_query = self.request.GET.mixed()
1126 1125 raise HTTPFound(
1127 1126 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1128 1127 _query=org_query))
1129 1128
1130 1129 # target repo we must have read permissions, and also later on
1131 1130 # we want to check branch permissions here
1132 1131 target_perm = HasRepoPermissionAny(
1133 1132 'repository.read', 'repository.write', 'repository.admin')(
1134 1133 target_db_repo.repo_name)
1135 1134 if not target_perm:
1136 1135 msg = _('Not Enough permissions to target repo `{}`.'.format(
1137 1136 target_db_repo.repo_name))
1138 1137 h.flash(msg, category='error')
1139 1138 # copy the args back to redirect
1140 1139 org_query = self.request.GET.mixed()
1141 1140 raise HTTPFound(
1142 1141 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1143 1142 _query=org_query))
1144 1143
1145 1144 source_scm = source_db_repo.scm_instance()
1146 1145 target_scm = target_db_repo.scm_instance()
1147 1146
1148 1147 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
1149 1148 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
1150 1149
1151 1150 ancestor = source_scm.get_common_ancestor(
1152 1151 source_commit.raw_id, target_commit.raw_id, target_scm)
1153 1152
1154 1153 # recalculate target ref based on ancestor
1155 1154 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
1156 1155 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
1157 1156
1158 1157 get_default_reviewers_data, validate_default_reviewers, validate_observers = \
1159 1158 PullRequestModel().get_reviewer_functions()
1160 1159
1161 1160 # recalculate reviewers logic, to make sure we can validate this
1162 1161 reviewer_rules = get_default_reviewers_data(
1163 1162 self._rhodecode_db_user, source_db_repo,
1164 1163 source_commit, target_db_repo, target_commit)
1165 1164
1166 1165 reviewers = validate_default_reviewers(_form['review_members'], reviewer_rules)
1167 1166 observers = validate_observers(_form['observer_members'], reviewer_rules)
1168 1167
1169 1168 pullrequest_title = _form['pullrequest_title']
1170 1169 title_source_ref = source_ref.split(':', 2)[1]
1171 1170 if not pullrequest_title:
1172 1171 pullrequest_title = PullRequestModel().generate_pullrequest_title(
1173 1172 source=source_repo,
1174 1173 source_ref=title_source_ref,
1175 1174 target=target_repo
1176 1175 )
1177 1176
1178 1177 description = _form['pullrequest_desc']
1179 1178 description_renderer = _form['description_renderer']
1180 1179
1181 1180 try:
1182 1181 pull_request = PullRequestModel().create(
1183 1182 created_by=self._rhodecode_user.user_id,
1184 1183 source_repo=source_repo,
1185 1184 source_ref=source_ref,
1186 1185 target_repo=target_repo,
1187 1186 target_ref=target_ref,
1188 1187 revisions=commit_ids,
1189 1188 common_ancestor_id=common_ancestor_id,
1190 1189 reviewers=reviewers,
1191 1190 observers=observers,
1192 1191 title=pullrequest_title,
1193 1192 description=description,
1194 1193 description_renderer=description_renderer,
1195 1194 reviewer_data=reviewer_rules,
1196 1195 auth_user=self._rhodecode_user
1197 1196 )
1198 1197 Session().commit()
1199 1198
1200 1199 h.flash(_('Successfully opened new pull request'),
1201 1200 category='success')
1202 1201 except Exception:
1203 1202 msg = _('Error occurred during creation of this pull request.')
1204 1203 log.exception(msg)
1205 1204 h.flash(msg, category='error')
1206 1205
1207 1206 # copy the args back to redirect
1208 1207 org_query = self.request.GET.mixed()
1209 1208 raise HTTPFound(
1210 1209 h.route_path('pullrequest_new', repo_name=self.db_repo_name,
1211 1210 _query=org_query))
1212 1211
1213 1212 raise HTTPFound(
1214 1213 h.route_path('pullrequest_show', repo_name=target_repo,
1215 1214 pull_request_id=pull_request.pull_request_id))
1216 1215
1217 1216 @LoginRequired()
1218 1217 @NotAnonymous()
1219 1218 @HasRepoPermissionAnyDecorator(
1220 1219 'repository.read', 'repository.write', 'repository.admin')
1221 1220 @CSRFRequired()
1222 1221 @view_config(
1223 1222 route_name='pullrequest_update', request_method='POST',
1224 1223 renderer='json_ext')
1225 1224 def pull_request_update(self):
1226 1225 pull_request = PullRequest.get_or_404(
1227 1226 self.request.matchdict['pull_request_id'])
1228 1227 _ = self.request.translate
1229 1228
1230 1229 c = self.load_default_context()
1231 1230 redirect_url = None
1232 1231
1233 1232 if pull_request.is_closed():
1234 1233 log.debug('update: forbidden because pull request is closed')
1235 1234 msg = _(u'Cannot update closed pull requests.')
1236 1235 h.flash(msg, category='error')
1237 1236 return {'response': True,
1238 1237 'redirect_url': redirect_url}
1239 1238
1240 1239 is_state_changing = pull_request.is_state_changing()
1241 c.pr_broadcast_channel = '/repo${}$/pr/{}'.format(
1242 pull_request.target_repo.repo_name, pull_request.pull_request_id)
1240 c.pr_broadcast_channel = channelstream.pr_channel(pull_request)
1243 1241
1244 1242 # only owner or admin can update it
1245 1243 allowed_to_update = PullRequestModel().check_user_update(
1246 1244 pull_request, self._rhodecode_user)
1247 1245
1248 1246 if allowed_to_update:
1249 1247 controls = peppercorn.parse(self.request.POST.items())
1250 1248 force_refresh = str2bool(self.request.POST.get('force_refresh'))
1251 1249
1252 1250 if 'review_members' in controls:
1253 1251 self._update_reviewers(
1254 1252 c,
1255 1253 pull_request, controls['review_members'],
1256 1254 pull_request.reviewer_data,
1257 1255 PullRequestReviewers.ROLE_REVIEWER)
1258 1256 elif 'observer_members' in controls:
1259 1257 self._update_reviewers(
1260 1258 c,
1261 1259 pull_request, controls['observer_members'],
1262 1260 pull_request.reviewer_data,
1263 1261 PullRequestReviewers.ROLE_OBSERVER)
1264 1262 elif str2bool(self.request.POST.get('update_commits', 'false')):
1265 1263 if is_state_changing:
1266 1264 log.debug('commits update: forbidden because pull request is in state %s',
1267 1265 pull_request.pull_request_state)
1268 1266 msg = _(u'Cannot update pull requests commits in state other than `{}`. '
1269 1267 u'Current state is: `{}`').format(
1270 1268 PullRequest.STATE_CREATED, pull_request.pull_request_state)
1271 1269 h.flash(msg, category='error')
1272 1270 return {'response': True,
1273 1271 'redirect_url': redirect_url}
1274 1272
1275 1273 self._update_commits(c, pull_request)
1276 1274 if force_refresh:
1277 1275 redirect_url = h.route_path(
1278 1276 'pullrequest_show', repo_name=self.db_repo_name,
1279 1277 pull_request_id=pull_request.pull_request_id,
1280 1278 _query={"force_refresh": 1})
1281 1279 elif str2bool(self.request.POST.get('edit_pull_request', 'false')):
1282 1280 self._edit_pull_request(pull_request)
1283 1281 else:
1284 1282 log.error('Unhandled update data.')
1285 1283 raise HTTPBadRequest()
1286 1284
1287 1285 return {'response': True,
1288 1286 'redirect_url': redirect_url}
1289 1287 raise HTTPForbidden()
1290 1288
1291 1289 def _edit_pull_request(self, pull_request):
1292 1290 """
1293 1291 Edit title and description
1294 1292 """
1295 1293 _ = self.request.translate
1296 1294
1297 1295 try:
1298 1296 PullRequestModel().edit(
1299 1297 pull_request,
1300 1298 self.request.POST.get('title'),
1301 1299 self.request.POST.get('description'),
1302 1300 self.request.POST.get('description_renderer'),
1303 1301 self._rhodecode_user)
1304 1302 except ValueError:
1305 1303 msg = _(u'Cannot update closed pull requests.')
1306 1304 h.flash(msg, category='error')
1307 1305 return
1308 1306 else:
1309 1307 Session().commit()
1310 1308
1311 1309 msg = _(u'Pull request title & description updated.')
1312 1310 h.flash(msg, category='success')
1313 1311 return
1314 1312
1315 1313 def _update_commits(self, c, pull_request):
1316 1314 _ = self.request.translate
1317 1315
1318 1316 with pull_request.set_state(PullRequest.STATE_UPDATING):
1319 1317 resp = PullRequestModel().update_commits(
1320 1318 pull_request, self._rhodecode_db_user)
1321 1319
1322 1320 if resp.executed:
1323 1321
1324 1322 if resp.target_changed and resp.source_changed:
1325 1323 changed = 'target and source repositories'
1326 1324 elif resp.target_changed and not resp.source_changed:
1327 1325 changed = 'target repository'
1328 1326 elif not resp.target_changed and resp.source_changed:
1329 1327 changed = 'source repository'
1330 1328 else:
1331 1329 changed = 'nothing'
1332 1330
1333 1331 msg = _(u'Pull request updated to "{source_commit_id}" with '
1334 1332 u'{count_added} added, {count_removed} removed commits. '
1335 1333 u'Source of changes: {change_source}.')
1336 1334 msg = msg.format(
1337 1335 source_commit_id=pull_request.source_ref_parts.commit_id,
1338 1336 count_added=len(resp.changes.added),
1339 1337 count_removed=len(resp.changes.removed),
1340 1338 change_source=changed)
1341 1339 h.flash(msg, category='success')
1342 self._pr_update_channelstream_push(c.pr_broadcast_channel, msg)
1340 channelstream.pr_update_channelstream_push(
1341 self.request, c.pr_broadcast_channel, self._rhodecode_user, msg)
1343 1342 else:
1344 1343 msg = PullRequestModel.UPDATE_STATUS_MESSAGES[resp.reason]
1345 1344 warning_reasons = [
1346 1345 UpdateFailureReason.NO_CHANGE,
1347 1346 UpdateFailureReason.WRONG_REF_TYPE,
1348 1347 ]
1349 1348 category = 'warning' if resp.reason in warning_reasons else 'error'
1350 1349 h.flash(msg, category=category)
1351 1350
1352 1351 def _update_reviewers(self, c, pull_request, review_members, reviewer_rules, role):
1353 1352 _ = self.request.translate
1354 1353
1355 1354 get_default_reviewers_data, validate_default_reviewers, validate_observers = \
1356 1355 PullRequestModel().get_reviewer_functions()
1357 1356
1358 1357 if role == PullRequestReviewers.ROLE_REVIEWER:
1359 1358 try:
1360 1359 reviewers = validate_default_reviewers(review_members, reviewer_rules)
1361 1360 except ValueError as e:
1362 1361 log.error('Reviewers Validation: {}'.format(e))
1363 1362 h.flash(e, category='error')
1364 1363 return
1365 1364
1366 1365 old_calculated_status = pull_request.calculated_review_status()
1367 1366 PullRequestModel().update_reviewers(
1368 1367 pull_request, reviewers, self._rhodecode_user)
1369 1368
1370 1369 Session().commit()
1371 1370
1372 1371 msg = _('Pull request reviewers updated.')
1373 1372 h.flash(msg, category='success')
1374 self._pr_update_channelstream_push(c.pr_broadcast_channel, msg)
1373 channelstream.pr_update_channelstream_push(
1374 self.request, c.pr_broadcast_channel, self._rhodecode_user, msg)
1375 1375
1376 1376 # trigger status changed if change in reviewers changes the status
1377 1377 calculated_status = pull_request.calculated_review_status()
1378 1378 if old_calculated_status != calculated_status:
1379 1379 PullRequestModel().trigger_pull_request_hook(
1380 1380 pull_request, self._rhodecode_user, 'review_status_change',
1381 1381 data={'status': calculated_status})
1382 1382
1383 1383 elif role == PullRequestReviewers.ROLE_OBSERVER:
1384 1384 try:
1385 1385 observers = validate_observers(review_members, reviewer_rules)
1386 1386 except ValueError as e:
1387 1387 log.error('Observers Validation: {}'.format(e))
1388 1388 h.flash(e, category='error')
1389 1389 return
1390 1390
1391 1391 PullRequestModel().update_observers(
1392 1392 pull_request, observers, self._rhodecode_user)
1393 1393
1394 1394 Session().commit()
1395 1395 msg = _('Pull request observers updated.')
1396 1396 h.flash(msg, category='success')
1397 self._pr_update_channelstream_push(c.pr_broadcast_channel, msg)
1397 channelstream.pr_update_channelstream_push(
1398 self.request, c.pr_broadcast_channel, self._rhodecode_user, msg)
1398 1399
1399 1400 @LoginRequired()
1400 1401 @NotAnonymous()
1401 1402 @HasRepoPermissionAnyDecorator(
1402 1403 'repository.read', 'repository.write', 'repository.admin')
1403 1404 @CSRFRequired()
1404 1405 @view_config(
1405 1406 route_name='pullrequest_merge', request_method='POST',
1406 1407 renderer='json_ext')
1407 1408 def pull_request_merge(self):
1408 1409 """
1409 1410 Merge will perform a server-side merge of the specified
1410 1411 pull request, if the pull request is approved and mergeable.
1411 1412 After successful merging, the pull request is automatically
1412 1413 closed, with a relevant comment.
1413 1414 """
1414 1415 pull_request = PullRequest.get_or_404(
1415 1416 self.request.matchdict['pull_request_id'])
1416 1417 _ = self.request.translate
1417 1418
1418 1419 if pull_request.is_state_changing():
1419 1420 log.debug('show: forbidden because pull request is in state %s',
1420 1421 pull_request.pull_request_state)
1421 1422 msg = _(u'Cannot merge pull requests in state other than `{}`. '
1422 1423 u'Current state is: `{}`').format(PullRequest.STATE_CREATED,
1423 1424 pull_request.pull_request_state)
1424 1425 h.flash(msg, category='error')
1425 1426 raise HTTPFound(
1426 1427 h.route_path('pullrequest_show',
1427 1428 repo_name=pull_request.target_repo.repo_name,
1428 1429 pull_request_id=pull_request.pull_request_id))
1429 1430
1430 1431 self.load_default_context()
1431 1432
1432 1433 with pull_request.set_state(PullRequest.STATE_UPDATING):
1433 1434 check = MergeCheck.validate(
1434 1435 pull_request, auth_user=self._rhodecode_user,
1435 1436 translator=self.request.translate)
1436 1437 merge_possible = not check.failed
1437 1438
1438 1439 for err_type, error_msg in check.errors:
1439 1440 h.flash(error_msg, category=err_type)
1440 1441
1441 1442 if merge_possible:
1442 1443 log.debug("Pre-conditions checked, trying to merge.")
1443 1444 extras = vcs_operation_context(
1444 1445 self.request.environ, repo_name=pull_request.target_repo.repo_name,
1445 1446 username=self._rhodecode_db_user.username, action='push',
1446 1447 scm=pull_request.target_repo.repo_type)
1447 1448 with pull_request.set_state(PullRequest.STATE_UPDATING):
1448 1449 self._merge_pull_request(
1449 1450 pull_request, self._rhodecode_db_user, extras)
1450 1451 else:
1451 1452 log.debug("Pre-conditions failed, NOT merging.")
1452 1453
1453 1454 raise HTTPFound(
1454 1455 h.route_path('pullrequest_show',
1455 1456 repo_name=pull_request.target_repo.repo_name,
1456 1457 pull_request_id=pull_request.pull_request_id))
1457 1458
1458 1459 def _merge_pull_request(self, pull_request, user, extras):
1459 1460 _ = self.request.translate
1460 1461 merge_resp = PullRequestModel().merge_repo(pull_request, user, extras=extras)
1461 1462
1462 1463 if merge_resp.executed:
1463 1464 log.debug("The merge was successful, closing the pull request.")
1464 1465 PullRequestModel().close_pull_request(
1465 1466 pull_request.pull_request_id, user)
1466 1467 Session().commit()
1467 1468 msg = _('Pull request was successfully merged and closed.')
1468 1469 h.flash(msg, category='success')
1469 1470 else:
1470 1471 log.debug(
1471 1472 "The merge was not successful. Merge response: %s", merge_resp)
1472 1473 msg = merge_resp.merge_status_message
1473 1474 h.flash(msg, category='error')
1474 1475
1475 1476 @LoginRequired()
1476 1477 @NotAnonymous()
1477 1478 @HasRepoPermissionAnyDecorator(
1478 1479 'repository.read', 'repository.write', 'repository.admin')
1479 1480 @CSRFRequired()
1480 1481 @view_config(
1481 1482 route_name='pullrequest_delete', request_method='POST',
1482 1483 renderer='json_ext')
1483 1484 def pull_request_delete(self):
1484 1485 _ = self.request.translate
1485 1486
1486 1487 pull_request = PullRequest.get_or_404(
1487 1488 self.request.matchdict['pull_request_id'])
1488 1489 self.load_default_context()
1489 1490
1490 1491 pr_closed = pull_request.is_closed()
1491 1492 allowed_to_delete = PullRequestModel().check_user_delete(
1492 1493 pull_request, self._rhodecode_user) and not pr_closed
1493 1494
1494 1495 # only owner can delete it !
1495 1496 if allowed_to_delete:
1496 1497 PullRequestModel().delete(pull_request, self._rhodecode_user)
1497 1498 Session().commit()
1498 1499 h.flash(_('Successfully deleted pull request'),
1499 1500 category='success')
1500 1501 raise HTTPFound(h.route_path('pullrequest_show_all',
1501 1502 repo_name=self.db_repo_name))
1502 1503
1503 1504 log.warning('user %s tried to delete pull request without access',
1504 1505 self._rhodecode_user)
1505 1506 raise HTTPNotFound()
1506 1507
1507 1508 @LoginRequired()
1508 1509 @NotAnonymous()
1509 1510 @HasRepoPermissionAnyDecorator(
1510 1511 'repository.read', 'repository.write', 'repository.admin')
1511 1512 @CSRFRequired()
1512 1513 @view_config(
1513 1514 route_name='pullrequest_comment_create', request_method='POST',
1514 1515 renderer='json_ext')
1515 1516 def pull_request_comment_create(self):
1516 1517 _ = self.request.translate
1517 1518
1518 1519 pull_request = PullRequest.get_or_404(
1519 1520 self.request.matchdict['pull_request_id'])
1520 1521 pull_request_id = pull_request.pull_request_id
1521 1522
1522 1523 if pull_request.is_closed():
1523 1524 log.debug('comment: forbidden because pull request is closed')
1524 1525 raise HTTPForbidden()
1525 1526
1526 1527 allowed_to_comment = PullRequestModel().check_user_comment(
1527 1528 pull_request, self._rhodecode_user)
1528 1529 if not allowed_to_comment:
1529 1530 log.debug('comment: forbidden because pull request is from forbidden repo')
1530 1531 raise HTTPForbidden()
1531 1532
1532 1533 c = self.load_default_context()
1533 1534
1534 1535 status = self.request.POST.get('changeset_status', None)
1535 1536 text = self.request.POST.get('text')
1536 1537 comment_type = self.request.POST.get('comment_type')
1537 1538 resolves_comment_id = self.request.POST.get('resolves_comment_id', None)
1538 1539 close_pull_request = self.request.POST.get('close_pull_request')
1539 1540
1540 1541 # the logic here should work like following, if we submit close
1541 1542 # pr comment, use `close_pull_request_with_comment` function
1542 1543 # else handle regular comment logic
1543 1544
1544 1545 if close_pull_request:
1545 1546 # only owner or admin or person with write permissions
1546 1547 allowed_to_close = PullRequestModel().check_user_update(
1547 1548 pull_request, self._rhodecode_user)
1548 1549 if not allowed_to_close:
1549 1550 log.debug('comment: forbidden because not allowed to close '
1550 1551 'pull request %s', pull_request_id)
1551 1552 raise HTTPForbidden()
1552 1553
1553 1554 # This also triggers `review_status_change`
1554 1555 comment, status = PullRequestModel().close_pull_request_with_comment(
1555 1556 pull_request, self._rhodecode_user, self.db_repo, message=text,
1556 1557 auth_user=self._rhodecode_user)
1557 1558 Session().flush()
1558 1559
1559 1560 PullRequestModel().trigger_pull_request_hook(
1560 1561 pull_request, self._rhodecode_user, 'comment',
1561 1562 data={'comment': comment})
1562 1563
1563 1564 else:
1564 1565 # regular comment case, could be inline, or one with status.
1565 1566 # for that one we check also permissions
1566 1567
1567 1568 allowed_to_change_status = PullRequestModel().check_user_change_status(
1568 1569 pull_request, self._rhodecode_user)
1569 1570
1570 1571 if status and allowed_to_change_status:
1571 1572 message = (_('Status change %(transition_icon)s %(status)s')
1572 1573 % {'transition_icon': '>',
1573 1574 'status': ChangesetStatus.get_status_lbl(status)})
1574 1575 text = text or message
1575 1576
1576 1577 comment = CommentsModel().create(
1577 1578 text=text,
1578 1579 repo=self.db_repo.repo_id,
1579 1580 user=self._rhodecode_user.user_id,
1580 1581 pull_request=pull_request,
1581 1582 f_path=self.request.POST.get('f_path'),
1582 1583 line_no=self.request.POST.get('line'),
1583 1584 status_change=(ChangesetStatus.get_status_lbl(status)
1584 1585 if status and allowed_to_change_status else None),
1585 1586 status_change_type=(status
1586 1587 if status and allowed_to_change_status else None),
1587 1588 comment_type=comment_type,
1588 1589 resolves_comment_id=resolves_comment_id,
1589 1590 auth_user=self._rhodecode_user
1590 1591 )
1592 is_inline = bool(comment.f_path and comment.line_no)
1591 1593
1592 1594 if allowed_to_change_status:
1593 1595 # calculate old status before we change it
1594 1596 old_calculated_status = pull_request.calculated_review_status()
1595 1597
1596 1598 # get status if set !
1597 1599 if status:
1598 1600 ChangesetStatusModel().set_status(
1599 1601 self.db_repo.repo_id,
1600 1602 status,
1601 1603 self._rhodecode_user.user_id,
1602 1604 comment,
1603 1605 pull_request=pull_request
1604 1606 )
1605 1607
1606 1608 Session().flush()
1607 1609 # this is somehow required to get access to some relationship
1608 1610 # loaded on comment
1609 1611 Session().refresh(comment)
1610 1612
1611 1613 PullRequestModel().trigger_pull_request_hook(
1612 1614 pull_request, self._rhodecode_user, 'comment',
1613 1615 data={'comment': comment})
1614 1616
1615 1617 # we now calculate the status of pull request, and based on that
1616 1618 # calculation we set the commits status
1617 1619 calculated_status = pull_request.calculated_review_status()
1618 1620 if old_calculated_status != calculated_status:
1619 1621 PullRequestModel().trigger_pull_request_hook(
1620 1622 pull_request, self._rhodecode_user, 'review_status_change',
1621 1623 data={'status': calculated_status})
1622 1624
1623 1625 Session().commit()
1624 1626
1625 1627 data = {
1626 1628 'target_id': h.safeid(h.safe_unicode(
1627 1629 self.request.POST.get('f_path'))),
1628 1630 }
1629 1631 if comment:
1630 1632 c.co = comment
1631 1633 c.at_version_num = None
1632 1634 rendered_comment = render(
1633 1635 'rhodecode:templates/changeset/changeset_comment_block.mako',
1634 1636 self._get_template_context(c), self.request)
1635 1637
1636 1638 data.update(comment.get_dict())
1637 1639 data.update({'rendered_text': rendered_comment})
1638 1640
1641 comment_broadcast_channel = channelstream.comment_channel(
1642 self.db_repo_name, pull_request_obj=pull_request)
1643
1644 comment_data = data
1645 comment_type = 'inline' if is_inline else 'general'
1646 channelstream.comment_channelstream_push(
1647 self.request, comment_broadcast_channel, self._rhodecode_user,
1648 _('posted a new {} comment').format(comment_type),
1649 comment_data=comment_data)
1650
1639 1651 return data
1640 1652
1641 1653 @LoginRequired()
1642 1654 @NotAnonymous()
1643 1655 @HasRepoPermissionAnyDecorator(
1644 1656 'repository.read', 'repository.write', 'repository.admin')
1645 1657 @CSRFRequired()
1646 1658 @view_config(
1647 1659 route_name='pullrequest_comment_delete', request_method='POST',
1648 1660 renderer='json_ext')
1649 1661 def pull_request_comment_delete(self):
1650 1662 pull_request = PullRequest.get_or_404(
1651 1663 self.request.matchdict['pull_request_id'])
1652 1664
1653 1665 comment = ChangesetComment.get_or_404(
1654 1666 self.request.matchdict['comment_id'])
1655 1667 comment_id = comment.comment_id
1656 1668
1657 1669 if comment.immutable:
1658 1670 # don't allow deleting comments that are immutable
1659 1671 raise HTTPForbidden()
1660 1672
1661 1673 if pull_request.is_closed():
1662 1674 log.debug('comment: forbidden because pull request is closed')
1663 1675 raise HTTPForbidden()
1664 1676
1665 1677 if not comment:
1666 1678 log.debug('Comment with id:%s not found, skipping', comment_id)
1667 1679 # comment already deleted in another call probably
1668 1680 return True
1669 1681
1670 1682 if comment.pull_request.is_closed():
1671 1683 # don't allow deleting comments on closed pull request
1672 1684 raise HTTPForbidden()
1673 1685
1674 1686 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1675 1687 super_admin = h.HasPermissionAny('hg.admin')()
1676 1688 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1677 1689 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1678 1690 comment_repo_admin = is_repo_admin and is_repo_comment
1679 1691
1680 1692 if super_admin or comment_owner or comment_repo_admin:
1681 1693 old_calculated_status = comment.pull_request.calculated_review_status()
1682 1694 CommentsModel().delete(comment=comment, auth_user=self._rhodecode_user)
1683 1695 Session().commit()
1684 1696 calculated_status = comment.pull_request.calculated_review_status()
1685 1697 if old_calculated_status != calculated_status:
1686 1698 PullRequestModel().trigger_pull_request_hook(
1687 1699 comment.pull_request, self._rhodecode_user, 'review_status_change',
1688 1700 data={'status': calculated_status})
1689 1701 return True
1690 1702 else:
1691 1703 log.warning('No permissions for user %s to delete comment_id: %s',
1692 1704 self._rhodecode_db_user, comment_id)
1693 1705 raise HTTPNotFound()
1694 1706
1695 1707 @LoginRequired()
1696 1708 @NotAnonymous()
1697 1709 @HasRepoPermissionAnyDecorator(
1698 1710 'repository.read', 'repository.write', 'repository.admin')
1699 1711 @CSRFRequired()
1700 1712 @view_config(
1701 1713 route_name='pullrequest_comment_edit', request_method='POST',
1702 1714 renderer='json_ext')
1703 1715 def pull_request_comment_edit(self):
1704 1716 self.load_default_context()
1705 1717
1706 1718 pull_request = PullRequest.get_or_404(
1707 1719 self.request.matchdict['pull_request_id']
1708 1720 )
1709 1721 comment = ChangesetComment.get_or_404(
1710 1722 self.request.matchdict['comment_id']
1711 1723 )
1712 1724 comment_id = comment.comment_id
1713 1725
1714 1726 if comment.immutable:
1715 1727 # don't allow deleting comments that are immutable
1716 1728 raise HTTPForbidden()
1717 1729
1718 1730 if pull_request.is_closed():
1719 1731 log.debug('comment: forbidden because pull request is closed')
1720 1732 raise HTTPForbidden()
1721 1733
1722 1734 if not comment:
1723 1735 log.debug('Comment with id:%s not found, skipping', comment_id)
1724 1736 # comment already deleted in another call probably
1725 1737 return True
1726 1738
1727 1739 if comment.pull_request.is_closed():
1728 1740 # don't allow deleting comments on closed pull request
1729 1741 raise HTTPForbidden()
1730 1742
1731 1743 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(self.db_repo_name)
1732 1744 super_admin = h.HasPermissionAny('hg.admin')()
1733 1745 comment_owner = comment.author.user_id == self._rhodecode_user.user_id
1734 1746 is_repo_comment = comment.repo.repo_name == self.db_repo_name
1735 1747 comment_repo_admin = is_repo_admin and is_repo_comment
1736 1748
1737 1749 if super_admin or comment_owner or comment_repo_admin:
1738 1750 text = self.request.POST.get('text')
1739 1751 version = self.request.POST.get('version')
1740 1752 if text == comment.text:
1741 1753 log.warning(
1742 1754 'Comment(PR): '
1743 1755 'Trying to create new version '
1744 1756 'with the same comment body {}'.format(
1745 1757 comment_id,
1746 1758 )
1747 1759 )
1748 1760 raise HTTPNotFound()
1749 1761
1750 1762 if version.isdigit():
1751 1763 version = int(version)
1752 1764 else:
1753 1765 log.warning(
1754 1766 'Comment(PR): Wrong version type {} {} '
1755 1767 'for comment {}'.format(
1756 1768 version,
1757 1769 type(version),
1758 1770 comment_id,
1759 1771 )
1760 1772 )
1761 1773 raise HTTPNotFound()
1762 1774
1763 1775 try:
1764 1776 comment_history = CommentsModel().edit(
1765 1777 comment_id=comment_id,
1766 1778 text=text,
1767 1779 auth_user=self._rhodecode_user,
1768 1780 version=version,
1769 1781 )
1770 1782 except CommentVersionMismatch:
1771 1783 raise HTTPConflict()
1772 1784
1773 1785 if not comment_history:
1774 1786 raise HTTPNotFound()
1775 1787
1776 1788 Session().commit()
1777 1789
1778 1790 PullRequestModel().trigger_pull_request_hook(
1779 1791 pull_request, self._rhodecode_user, 'comment_edit',
1780 1792 data={'comment': comment})
1781 1793
1782 1794 return {
1783 1795 'comment_history_id': comment_history.comment_history_id,
1784 1796 'comment_id': comment.comment_id,
1785 1797 'comment_version': comment_history.version,
1786 1798 'comment_author_username': comment_history.author.username,
1787 1799 'comment_author_gravatar': h.gravatar_url(comment_history.author.email, 16),
1788 1800 'comment_created_on': h.age_component(comment_history.created_on,
1789 1801 time_is_local=True),
1790 1802 }
1791 1803 else:
1792 1804 log.warning('No permissions for user %s to edit comment_id: %s',
1793 1805 self._rhodecode_db_user, comment_id)
1794 1806 raise HTTPNotFound()
@@ -1,267 +1,371 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import os
22 22 import hashlib
23 23 import itsdangerous
24 24 import logging
25 25 import requests
26 26 import datetime
27 27
28 28 from dogpile.core import ReadWriteMutex
29 29 from pyramid.threadlocal import get_current_registry
30 30
31 31 import rhodecode.lib.helpers as h
32 32 from rhodecode.lib.auth import HasRepoPermissionAny
33 33 from rhodecode.lib.ext_json import json
34 34 from rhodecode.model.db import User
35 35
36 36 log = logging.getLogger(__name__)
37 37
38 38 LOCK = ReadWriteMutex()
39 39
40 40 USER_STATE_PUBLIC_KEYS = [
41 41 'id', 'username', 'first_name', 'last_name',
42 42 'icon_link', 'display_name', 'display_link']
43 43
44 44
45 45 class ChannelstreamException(Exception):
46 46 pass
47 47
48 48
49 49 class ChannelstreamConnectionException(ChannelstreamException):
50 50 pass
51 51
52 52
53 53 class ChannelstreamPermissionException(ChannelstreamException):
54 54 pass
55 55
56 56
57 57 def get_channelstream_server_url(config, endpoint):
58 58 return 'http://{}{}'.format(config['server'], endpoint)
59 59
60 60
61 61 def channelstream_request(config, payload, endpoint, raise_exc=True):
62 62 signer = itsdangerous.TimestampSigner(config['secret'])
63 63 sig_for_server = signer.sign(endpoint)
64 64 secret_headers = {'x-channelstream-secret': sig_for_server,
65 65 'x-channelstream-endpoint': endpoint,
66 66 'Content-Type': 'application/json'}
67 67 req_url = get_channelstream_server_url(config, endpoint)
68 68
69 69 log.debug('Sending a channelstream request to endpoint: `%s`', req_url)
70 70 response = None
71 71 try:
72 72 response = requests.post(req_url, data=json.dumps(payload),
73 73 headers=secret_headers).json()
74 74 except requests.ConnectionError:
75 75 log.exception('ConnectionError occurred for endpoint %s', req_url)
76 76 if raise_exc:
77 77 raise ChannelstreamConnectionException(req_url)
78 78 except Exception:
79 79 log.exception('Exception related to Channelstream happened')
80 80 if raise_exc:
81 81 raise ChannelstreamConnectionException()
82 82 log.debug('Got channelstream response: %s', response)
83 83 return response
84 84
85 85
86 86 def get_user_data(user_id):
87 87 user = User.get(user_id)
88 88 return {
89 89 'id': user.user_id,
90 90 'username': user.username,
91 91 'first_name': user.first_name,
92 92 'last_name': user.last_name,
93 93 'icon_link': h.gravatar_url(user.email, 60),
94 94 'display_name': h.person(user, 'username_or_name_or_email'),
95 95 'display_link': h.link_to_user(user),
96 96 'notifications': user.user_data.get('notification_status', True)
97 97 }
98 98
99 99
100 100 def broadcast_validator(channel_name):
101 101 """ checks if user can access the broadcast channel """
102 102 if channel_name == 'broadcast':
103 103 return True
104 104
105 105
106 106 def repo_validator(channel_name):
107 107 """ checks if user can access the broadcast channel """
108 108 channel_prefix = '/repo$'
109 109 if channel_name.startswith(channel_prefix):
110 110 elements = channel_name[len(channel_prefix):].split('$')
111 111 repo_name = elements[0]
112 112 can_access = HasRepoPermissionAny(
113 113 'repository.read',
114 114 'repository.write',
115 115 'repository.admin')(repo_name)
116 116 log.debug(
117 117 'permission check for %s channel resulted in %s',
118 118 repo_name, can_access)
119 119 if can_access:
120 120 return True
121 121 return False
122 122
123 123
124 124 def check_channel_permissions(channels, plugin_validators, should_raise=True):
125 125 valid_channels = []
126 126
127 127 validators = [broadcast_validator, repo_validator]
128 128 if plugin_validators:
129 129 validators.extend(plugin_validators)
130 130 for channel_name in channels:
131 131 is_valid = False
132 132 for validator in validators:
133 133 if validator(channel_name):
134 134 is_valid = True
135 135 break
136 136 if is_valid:
137 137 valid_channels.append(channel_name)
138 138 else:
139 139 if should_raise:
140 140 raise ChannelstreamPermissionException()
141 141 return valid_channels
142 142
143 143
144 144 def get_channels_info(self, channels):
145 145 payload = {'channels': channels}
146 146 # gather persistence info
147 147 return channelstream_request(self._config(), payload, '/info')
148 148
149 149
150 150 def parse_channels_info(info_result, include_channel_info=None):
151 151 """
152 152 Returns data that contains only secure information that can be
153 153 presented to clients
154 154 """
155 155 include_channel_info = include_channel_info or []
156 156
157 157 user_state_dict = {}
158 158 for userinfo in info_result['users']:
159 159 user_state_dict[userinfo['user']] = {
160 160 k: v for k, v in userinfo['state'].items()
161 161 if k in USER_STATE_PUBLIC_KEYS
162 162 }
163 163
164 164 channels_info = {}
165 165
166 166 for c_name, c_info in info_result['channels'].items():
167 167 if c_name not in include_channel_info:
168 168 continue
169 169 connected_list = []
170 170 for username in c_info['users']:
171 171 connected_list.append({
172 172 'user': username,
173 173 'state': user_state_dict[username]
174 174 })
175 175 channels_info[c_name] = {'users': connected_list,
176 176 'history': c_info['history']}
177 177
178 178 return channels_info
179 179
180 180
181 181 def log_filepath(history_location, channel_name):
182 182 hasher = hashlib.sha256()
183 183 hasher.update(channel_name.encode('utf8'))
184 184 filename = '{}.log'.format(hasher.hexdigest())
185 185 filepath = os.path.join(history_location, filename)
186 186 return filepath
187 187
188 188
189 189 def read_history(history_location, channel_name):
190 190 filepath = log_filepath(history_location, channel_name)
191 191 if not os.path.exists(filepath):
192 192 return []
193 193 history_lines_limit = -100
194 194 history = []
195 195 with open(filepath, 'rb') as f:
196 196 for line in f.readlines()[history_lines_limit:]:
197 197 try:
198 198 history.append(json.loads(line))
199 199 except Exception:
200 200 log.exception('Failed to load history')
201 201 return history
202 202
203 203
204 204 def update_history_from_logs(config, channels, payload):
205 205 history_location = config.get('history.location')
206 206 for channel in channels:
207 207 history = read_history(history_location, channel)
208 208 payload['channels_info'][channel]['history'] = history
209 209
210 210
211 211 def write_history(config, message):
212 212 """ writes a messge to a base64encoded filename """
213 213 history_location = config.get('history.location')
214 214 if not os.path.exists(history_location):
215 215 return
216 216 try:
217 217 LOCK.acquire_write_lock()
218 218 filepath = log_filepath(history_location, message['channel'])
219 219 with open(filepath, 'ab') as f:
220 220 json.dump(message, f)
221 221 f.write('\n')
222 222 finally:
223 223 LOCK.release_write_lock()
224 224
225 225
226 226 def get_connection_validators(registry):
227 227 validators = []
228 for k, config in registry.rhodecode_plugins.iteritems():
228 for k, config in registry.rhodecode_plugins.items():
229 229 validator = config.get('channelstream', {}).get('connect_validator')
230 230 if validator:
231 231 validators.append(validator)
232 232 return validators
233 233
234 234
235 def get_channelstream_config(registry=None):
236 if not registry:
237 registry = get_current_registry()
238
239 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
240 channelstream_config = rhodecode_plugins.get('channelstream', {})
241 return channelstream_config
242
243
235 244 def post_message(channel, message, username, registry=None):
245 channelstream_config = get_channelstream_config(registry)
246 if not channelstream_config.get('enabled'):
247 return
236 248
237 249 message_obj = message
238 250 if isinstance(message, basestring):
239 251 message_obj = {
240 252 'message': message,
241 253 'level': 'success',
242 254 'topic': '/notifications'
243 255 }
244 256
245 if not registry:
246 registry = get_current_registry()
257 log.debug('Channelstream: sending notification to channel %s', channel)
258 payload = {
259 'type': 'message',
260 'timestamp': datetime.datetime.utcnow(),
261 'user': 'system',
262 'exclude_users': [username],
263 'channel': channel,
264 'message': message_obj
265 }
266
267 try:
268 return channelstream_request(
269 channelstream_config, [payload], '/message',
270 raise_exc=False)
271 except ChannelstreamException:
272 log.exception('Failed to send channelstream data')
273 raise
274
275
276 def _reload_link(label):
277 return (
278 '<a onclick="window.location.reload()">'
279 '<strong>{}</strong>'
280 '</a>'.format(label)
281 )
282
283
284 def pr_channel(pull_request):
285 repo_name = pull_request.target_repo.repo_name
286 pull_request_id = pull_request.pull_request_id
287 channel = '/repo${}$/pr/{}'.format(repo_name, pull_request_id)
288 log.debug('Getting pull-request channelstream broadcast channel: %s', channel)
289 return channel
290
291
292 def comment_channel(repo_name, commit_obj=None, pull_request_obj=None):
293 channel = None
294 if commit_obj:
295 channel = u'/repo${}$/commit/{}'.format(
296 repo_name, commit_obj.raw_id
297 )
298 elif pull_request_obj:
299 channel = u'/repo${}$/pr/{}'.format(
300 repo_name, pull_request_obj.pull_request_id
301 )
302 log.debug('Getting comment channelstream broadcast channel: %s', channel)
303
304 return channel
305
306
307 def pr_update_channelstream_push(request, pr_broadcast_channel, user, msg, **kwargs):
308 """
309 Channel push on pull request update
310 """
311 if not pr_broadcast_channel:
312 return
247 313
248 log.debug('Channelstream: sending notification to channel %s', channel)
249 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
250 channelstream_config = rhodecode_plugins.get('channelstream', {})
251 if channelstream_config.get('enabled'):
252 payload = {
253 'type': 'message',
254 'timestamp': datetime.datetime.utcnow(),
255 'user': 'system',
256 'exclude_users': [username],
257 'channel': channel,
258 'message': message_obj
259 }
314 _ = request.translate
315
316 message = '{} {}'.format(
317 msg,
318 _reload_link(_(' Reload page to load changes')))
319
320 message_obj = {
321 'message': message,
322 'level': 'success',
323 'topic': '/notifications'
324 }
325
326 post_message(
327 pr_broadcast_channel, message_obj, user.username,
328 registry=request.registry)
329
330
331 def comment_channelstream_push(request, comment_broadcast_channel, user, msg, **kwargs):
332 """
333 Channelstream push on comment action, on commit, or pull-request
334 """
335 if not comment_broadcast_channel:
336 return
337
338 _ = request.translate
260 339
261 try:
262 return channelstream_request(
263 channelstream_config, [payload], '/message',
264 raise_exc=False)
265 except ChannelstreamException:
266 log.exception('Failed to send channelstream data')
267 raise
340 comment_data = kwargs.pop('comment_data', {})
341 user_data = kwargs.pop('user_data', {})
342 comment_id = comment_data.get('comment_id')
343
344 message = '<strong>{}</strong> {} #{}, {}'.format(
345 user.username,
346 msg,
347 comment_id,
348 _reload_link(_('Reload page to see new comments')),
349 )
350
351 message_obj = {
352 'message': message,
353 'level': 'success',
354 'topic': '/notifications'
355 }
356
357 post_message(
358 comment_broadcast_channel, message_obj, user.username,
359 registry=request.registry)
360
361 message_obj = {
362 'message': None,
363 'user': user.username,
364 'comment_id': comment_id,
365 'comment_data': comment_data,
366 'user_data': user_data,
367 'topic': '/comment'
368 }
369 post_message(
370 comment_broadcast_channel, message_obj, user.username,
371 registry=request.registry)
@@ -1,862 +1,818 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 comments model for RhodeCode
23 23 """
24 24 import datetime
25 25
26 26 import logging
27 27 import traceback
28 28 import collections
29 29
30 30 from pyramid.threadlocal import get_current_registry, get_current_request
31 31 from sqlalchemy.sql.expression import null
32 32 from sqlalchemy.sql.functions import coalesce
33 33
34 34 from rhodecode.lib import helpers as h, diffs, channelstream, hooks_utils
35 35 from rhodecode.lib import audit_logger
36 36 from rhodecode.lib.exceptions import CommentVersionMismatch
37 37 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str, safe_int
38 38 from rhodecode.model import BaseModel
39 39 from rhodecode.model.db import (
40 40 ChangesetComment,
41 41 User,
42 42 Notification,
43 43 PullRequest,
44 44 AttributeDict,
45 45 ChangesetCommentHistory,
46 46 )
47 47 from rhodecode.model.notification import NotificationModel
48 48 from rhodecode.model.meta import Session
49 49 from rhodecode.model.settings import VcsSettingsModel
50 50 from rhodecode.model.notification import EmailNotificationModel
51 51 from rhodecode.model.validation_schema.schemas import comment_schema
52 52
53 53
54 54 log = logging.getLogger(__name__)
55 55
56 56
57 57 class CommentsModel(BaseModel):
58 58
59 59 cls = ChangesetComment
60 60
61 61 DIFF_CONTEXT_BEFORE = 3
62 62 DIFF_CONTEXT_AFTER = 3
63 63
64 64 def __get_commit_comment(self, changeset_comment):
65 65 return self._get_instance(ChangesetComment, changeset_comment)
66 66
67 67 def __get_pull_request(self, pull_request):
68 68 return self._get_instance(PullRequest, pull_request)
69 69
70 70 def _extract_mentions(self, s):
71 71 user_objects = []
72 72 for username in extract_mentioned_users(s):
73 73 user_obj = User.get_by_username(username, case_insensitive=True)
74 74 if user_obj:
75 75 user_objects.append(user_obj)
76 76 return user_objects
77 77
78 78 def _get_renderer(self, global_renderer='rst', request=None):
79 79 request = request or get_current_request()
80 80
81 81 try:
82 82 global_renderer = request.call_context.visual.default_renderer
83 83 except AttributeError:
84 84 log.debug("Renderer not set, falling back "
85 85 "to default renderer '%s'", global_renderer)
86 86 except Exception:
87 87 log.error(traceback.format_exc())
88 88 return global_renderer
89 89
90 90 def aggregate_comments(self, comments, versions, show_version, inline=False):
91 91 # group by versions, and count until, and display objects
92 92
93 93 comment_groups = collections.defaultdict(list)
94 94 [comment_groups[_co.pull_request_version_id].append(_co) for _co in comments]
95 95
96 96 def yield_comments(pos):
97 97 for co in comment_groups[pos]:
98 98 yield co
99 99
100 100 comment_versions = collections.defaultdict(
101 101 lambda: collections.defaultdict(list))
102 102 prev_prvid = -1
103 103 # fake last entry with None, to aggregate on "latest" version which
104 104 # doesn't have an pull_request_version_id
105 105 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
106 106 prvid = ver.pull_request_version_id
107 107 if prev_prvid == -1:
108 108 prev_prvid = prvid
109 109
110 110 for co in yield_comments(prvid):
111 111 comment_versions[prvid]['at'].append(co)
112 112
113 113 # save until
114 114 current = comment_versions[prvid]['at']
115 115 prev_until = comment_versions[prev_prvid]['until']
116 116 cur_until = prev_until + current
117 117 comment_versions[prvid]['until'].extend(cur_until)
118 118
119 119 # save outdated
120 120 if inline:
121 121 outdated = [x for x in cur_until
122 122 if x.outdated_at_version(show_version)]
123 123 else:
124 124 outdated = [x for x in cur_until
125 125 if x.older_than_version(show_version)]
126 126 display = [x for x in cur_until if x not in outdated]
127 127
128 128 comment_versions[prvid]['outdated'] = outdated
129 129 comment_versions[prvid]['display'] = display
130 130
131 131 prev_prvid = prvid
132 132
133 133 return comment_versions
134 134
135 135 def get_repository_comments(self, repo, comment_type=None, user=None, commit_id=None):
136 136 qry = Session().query(ChangesetComment) \
137 137 .filter(ChangesetComment.repo == repo)
138 138
139 139 if comment_type and comment_type in ChangesetComment.COMMENT_TYPES:
140 140 qry = qry.filter(ChangesetComment.comment_type == comment_type)
141 141
142 142 if user:
143 143 user = self._get_user(user)
144 144 if user:
145 145 qry = qry.filter(ChangesetComment.user_id == user.user_id)
146 146
147 147 if commit_id:
148 148 qry = qry.filter(ChangesetComment.revision == commit_id)
149 149
150 150 qry = qry.order_by(ChangesetComment.created_on)
151 151 return qry.all()
152 152
153 153 def get_repository_unresolved_todos(self, repo):
154 154 todos = Session().query(ChangesetComment) \
155 155 .filter(ChangesetComment.repo == repo) \
156 156 .filter(ChangesetComment.resolved_by == None) \
157 157 .filter(ChangesetComment.comment_type
158 158 == ChangesetComment.COMMENT_TYPE_TODO)
159 159 todos = todos.all()
160 160
161 161 return todos
162 162
163 163 def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True):
164 164
165 165 todos = Session().query(ChangesetComment) \
166 166 .filter(ChangesetComment.pull_request == pull_request) \
167 167 .filter(ChangesetComment.resolved_by == None) \
168 168 .filter(ChangesetComment.comment_type
169 169 == ChangesetComment.COMMENT_TYPE_TODO)
170 170
171 171 if not show_outdated:
172 172 todos = todos.filter(
173 173 coalesce(ChangesetComment.display_state, '') !=
174 174 ChangesetComment.COMMENT_OUTDATED)
175 175
176 176 todos = todos.all()
177 177
178 178 return todos
179 179
180 180 def get_pull_request_resolved_todos(self, pull_request, show_outdated=True):
181 181
182 182 todos = Session().query(ChangesetComment) \
183 183 .filter(ChangesetComment.pull_request == pull_request) \
184 184 .filter(ChangesetComment.resolved_by != None) \
185 185 .filter(ChangesetComment.comment_type
186 186 == ChangesetComment.COMMENT_TYPE_TODO)
187 187
188 188 if not show_outdated:
189 189 todos = todos.filter(
190 190 coalesce(ChangesetComment.display_state, '') !=
191 191 ChangesetComment.COMMENT_OUTDATED)
192 192
193 193 todos = todos.all()
194 194
195 195 return todos
196 196
197 197 def get_commit_unresolved_todos(self, commit_id, show_outdated=True):
198 198
199 199 todos = Session().query(ChangesetComment) \
200 200 .filter(ChangesetComment.revision == commit_id) \
201 201 .filter(ChangesetComment.resolved_by == None) \
202 202 .filter(ChangesetComment.comment_type
203 203 == ChangesetComment.COMMENT_TYPE_TODO)
204 204
205 205 if not show_outdated:
206 206 todos = todos.filter(
207 207 coalesce(ChangesetComment.display_state, '') !=
208 208 ChangesetComment.COMMENT_OUTDATED)
209 209
210 210 todos = todos.all()
211 211
212 212 return todos
213 213
214 214 def get_commit_resolved_todos(self, commit_id, show_outdated=True):
215 215
216 216 todos = Session().query(ChangesetComment) \
217 217 .filter(ChangesetComment.revision == commit_id) \
218 218 .filter(ChangesetComment.resolved_by != None) \
219 219 .filter(ChangesetComment.comment_type
220 220 == ChangesetComment.COMMENT_TYPE_TODO)
221 221
222 222 if not show_outdated:
223 223 todos = todos.filter(
224 224 coalesce(ChangesetComment.display_state, '') !=
225 225 ChangesetComment.COMMENT_OUTDATED)
226 226
227 227 todos = todos.all()
228 228
229 229 return todos
230 230
231 231 def get_commit_inline_comments(self, commit_id):
232 232 inline_comments = Session().query(ChangesetComment) \
233 233 .filter(ChangesetComment.line_no != None) \
234 234 .filter(ChangesetComment.f_path != None) \
235 235 .filter(ChangesetComment.revision == commit_id)
236 236 inline_comments = inline_comments.all()
237 237 return inline_comments
238 238
239 239 def _log_audit_action(self, action, action_data, auth_user, comment):
240 240 audit_logger.store(
241 241 action=action,
242 242 action_data=action_data,
243 243 user=auth_user,
244 244 repo=comment.repo)
245 245
246 246 def create(self, text, repo, user, commit_id=None, pull_request=None,
247 247 f_path=None, line_no=None, status_change=None,
248 248 status_change_type=None, comment_type=None,
249 249 resolves_comment_id=None, closing_pr=False, send_email=True,
250 250 renderer=None, auth_user=None, extra_recipients=None):
251 251 """
252 252 Creates new comment for commit or pull request.
253 253 IF status_change is not none this comment is associated with a
254 254 status change of commit or commit associated with pull request
255 255
256 256 :param text:
257 257 :param repo:
258 258 :param user:
259 259 :param commit_id:
260 260 :param pull_request:
261 261 :param f_path:
262 262 :param line_no:
263 263 :param status_change: Label for status change
264 264 :param comment_type: Type of comment
265 265 :param resolves_comment_id: id of comment which this one will resolve
266 266 :param status_change_type: type of status change
267 267 :param closing_pr:
268 268 :param send_email:
269 269 :param renderer: pick renderer for this comment
270 270 :param auth_user: current authenticated user calling this method
271 271 :param extra_recipients: list of extra users to be added to recipients
272 272 """
273 273
274 274 if not text:
275 275 log.warning('Missing text for comment, skipping...')
276 276 return
277 277 request = get_current_request()
278 278 _ = request.translate
279 279
280 280 if not renderer:
281 281 renderer = self._get_renderer(request=request)
282 282
283 283 repo = self._get_repo(repo)
284 284 user = self._get_user(user)
285 285 auth_user = auth_user or user
286 286
287 287 schema = comment_schema.CommentSchema()
288 288 validated_kwargs = schema.deserialize(dict(
289 289 comment_body=text,
290 290 comment_type=comment_type,
291 291 comment_file=f_path,
292 292 comment_line=line_no,
293 293 renderer_type=renderer,
294 294 status_change=status_change_type,
295 295 resolves_comment_id=resolves_comment_id,
296 296 repo=repo.repo_id,
297 297 user=user.user_id,
298 298 ))
299 299
300 300 comment = ChangesetComment()
301 301 comment.renderer = validated_kwargs['renderer_type']
302 302 comment.text = validated_kwargs['comment_body']
303 303 comment.f_path = validated_kwargs['comment_file']
304 304 comment.line_no = validated_kwargs['comment_line']
305 305 comment.comment_type = validated_kwargs['comment_type']
306 306
307 307 comment.repo = repo
308 308 comment.author = user
309 309 resolved_comment = self.__get_commit_comment(
310 310 validated_kwargs['resolves_comment_id'])
311 311 # check if the comment actually belongs to this PR
312 312 if resolved_comment and resolved_comment.pull_request and \
313 313 resolved_comment.pull_request != pull_request:
314 314 log.warning('Comment tried to resolved unrelated todo comment: %s',
315 315 resolved_comment)
316 316 # comment not bound to this pull request, forbid
317 317 resolved_comment = None
318 318
319 319 elif resolved_comment and resolved_comment.repo and \
320 320 resolved_comment.repo != repo:
321 321 log.warning('Comment tried to resolved unrelated todo comment: %s',
322 322 resolved_comment)
323 323 # comment not bound to this repo, forbid
324 324 resolved_comment = None
325 325
326 326 comment.resolved_comment = resolved_comment
327 327
328 328 pull_request_id = pull_request
329 329
330 330 commit_obj = None
331 331 pull_request_obj = None
332 332
333 333 if commit_id:
334 334 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
335 335 # do a lookup, so we don't pass something bad here
336 336 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
337 337 comment.revision = commit_obj.raw_id
338 338
339 339 elif pull_request_id:
340 340 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
341 341 pull_request_obj = self.__get_pull_request(pull_request_id)
342 342 comment.pull_request = pull_request_obj
343 343 else:
344 344 raise Exception('Please specify commit or pull_request_id')
345 345
346 346 Session().add(comment)
347 347 Session().flush()
348 348 kwargs = {
349 349 'user': user,
350 350 'renderer_type': renderer,
351 351 'repo_name': repo.repo_name,
352 352 'status_change': status_change,
353 353 'status_change_type': status_change_type,
354 354 'comment_body': text,
355 355 'comment_file': f_path,
356 356 'comment_line': line_no,
357 357 'comment_type': comment_type or 'note',
358 358 'comment_id': comment.comment_id
359 359 }
360 360
361 361 if commit_obj:
362 362 recipients = ChangesetComment.get_users(
363 363 revision=commit_obj.raw_id)
364 364 # add commit author if it's in RhodeCode system
365 365 cs_author = User.get_from_cs_author(commit_obj.author)
366 366 if not cs_author:
367 367 # use repo owner if we cannot extract the author correctly
368 368 cs_author = repo.user
369 369 recipients += [cs_author]
370 370
371 371 commit_comment_url = self.get_url(comment, request=request)
372 372 commit_comment_reply_url = self.get_url(
373 373 comment, request=request,
374 374 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
375 375
376 376 target_repo_url = h.link_to(
377 377 repo.repo_name,
378 378 h.route_url('repo_summary', repo_name=repo.repo_name))
379 379
380 380 commit_url = h.route_url('repo_commit', repo_name=repo.repo_name,
381 381 commit_id=commit_id)
382 382
383 383 # commit specifics
384 384 kwargs.update({
385 385 'commit': commit_obj,
386 386 'commit_message': commit_obj.message,
387 387 'commit_target_repo_url': target_repo_url,
388 388 'commit_comment_url': commit_comment_url,
389 389 'commit_comment_reply_url': commit_comment_reply_url,
390 390 'commit_url': commit_url,
391 391 'thread_ids': [commit_url, commit_comment_url],
392 392 })
393 393
394 394 elif pull_request_obj:
395 395 # get the current participants of this pull request
396 396 recipients = ChangesetComment.get_users(
397 397 pull_request_id=pull_request_obj.pull_request_id)
398 398 # add pull request author
399 399 recipients += [pull_request_obj.author]
400 400
401 401 # add the reviewers to notification
402 402 recipients += [x.user for x in pull_request_obj.reviewers]
403 403
404 404 pr_target_repo = pull_request_obj.target_repo
405 405 pr_source_repo = pull_request_obj.source_repo
406 406
407 407 pr_comment_url = self.get_url(comment, request=request)
408 408 pr_comment_reply_url = self.get_url(
409 409 comment, request=request,
410 410 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
411 411
412 412 pr_url = h.route_url(
413 413 'pullrequest_show',
414 414 repo_name=pr_target_repo.repo_name,
415 415 pull_request_id=pull_request_obj.pull_request_id, )
416 416
417 417 # set some variables for email notification
418 418 pr_target_repo_url = h.route_url(
419 419 'repo_summary', repo_name=pr_target_repo.repo_name)
420 420
421 421 pr_source_repo_url = h.route_url(
422 422 'repo_summary', repo_name=pr_source_repo.repo_name)
423 423
424 424 # pull request specifics
425 425 kwargs.update({
426 426 'pull_request': pull_request_obj,
427 427 'pr_id': pull_request_obj.pull_request_id,
428 428 'pull_request_url': pr_url,
429 429 'pull_request_target_repo': pr_target_repo,
430 430 'pull_request_target_repo_url': pr_target_repo_url,
431 431 'pull_request_source_repo': pr_source_repo,
432 432 'pull_request_source_repo_url': pr_source_repo_url,
433 433 'pr_comment_url': pr_comment_url,
434 434 'pr_comment_reply_url': pr_comment_reply_url,
435 435 'pr_closing': closing_pr,
436 436 'thread_ids': [pr_url, pr_comment_url],
437 437 })
438 438
439 439 if send_email:
440 440 recipients += [self._get_user(u) for u in (extra_recipients or [])]
441 441 # pre-generate the subject for notification itself
442 442 (subject, _e, body_plaintext) = EmailNotificationModel().render_email(
443 443 notification_type, **kwargs)
444 444
445 445 mention_recipients = set(
446 446 self._extract_mentions(text)).difference(recipients)
447 447
448 448 # create notification objects, and emails
449 449 NotificationModel().create(
450 450 created_by=user,
451 451 notification_subject=subject,
452 452 notification_body=body_plaintext,
453 453 notification_type=notification_type,
454 454 recipients=recipients,
455 455 mention_recipients=mention_recipients,
456 456 email_kwargs=kwargs,
457 457 )
458 458
459 459 Session().flush()
460 460 if comment.pull_request:
461 461 action = 'repo.pull_request.comment.create'
462 462 else:
463 463 action = 'repo.commit.comment.create'
464 464
465 comment_id = comment.comment_id
466 465 comment_data = comment.get_api_data()
467 466
468 467 self._log_audit_action(
469 468 action, {'data': comment_data}, auth_user, comment)
470 469
471 channel = None
472 if commit_obj:
473 repo_name = repo.repo_name
474 channel = u'/repo${}$/commit/{}'.format(
475 repo_name,
476 commit_obj.raw_id
477 )
478 elif pull_request_obj:
479 repo_name = pr_target_repo.repo_name
480 channel = u'/repo${}$/pr/{}'.format(
481 repo_name,
482 pull_request_obj.pull_request_id
483 )
484
485 if channel:
486 username = user.username
487 message = '<strong>{}</strong> {} #{}, {}'
488 message = message.format(
489 username,
490 _('posted a new comment'),
491 comment_id,
492 _('Refresh the page to see new comments.'))
493
494 message_obj = {
495 'message': message,
496 'level': 'success',
497 'topic': '/notifications'
498 }
499
500 channelstream.post_message(
501 channel, message_obj, user.username,
502 registry=get_current_registry())
503
504 message_obj = {
505 'message': None,
506 'user': username,
507 'comment_id': comment_id,
508 'topic': '/comment'
509 }
510 channelstream.post_message(
511 channel, message_obj, user.username,
512 registry=get_current_registry())
513
514 470 return comment
515 471
516 472 def edit(self, comment_id, text, auth_user, version):
517 473 """
518 474 Change existing comment for commit or pull request.
519 475
520 476 :param comment_id:
521 477 :param text:
522 478 :param auth_user: current authenticated user calling this method
523 479 :param version: last comment version
524 480 """
525 481 if not text:
526 482 log.warning('Missing text for comment, skipping...')
527 483 return
528 484
529 485 comment = ChangesetComment.get(comment_id)
530 486 old_comment_text = comment.text
531 487 comment.text = text
532 488 comment.modified_at = datetime.datetime.now()
533 489 version = safe_int(version)
534 490
535 491 # NOTE(marcink): this returns initial comment + edits, so v2 from ui
536 492 # would return 3 here
537 493 comment_version = ChangesetCommentHistory.get_version(comment_id)
538 494
539 495 if isinstance(version, (int, long)) and (comment_version - version) != 1:
540 496 log.warning(
541 497 'Version mismatch comment_version {} submitted {}, skipping'.format(
542 498 comment_version-1, # -1 since note above
543 499 version
544 500 )
545 501 )
546 502 raise CommentVersionMismatch()
547 503
548 504 comment_history = ChangesetCommentHistory()
549 505 comment_history.comment_id = comment_id
550 506 comment_history.version = comment_version
551 507 comment_history.created_by_user_id = auth_user.user_id
552 508 comment_history.text = old_comment_text
553 509 # TODO add email notification
554 510 Session().add(comment_history)
555 511 Session().add(comment)
556 512 Session().flush()
557 513
558 514 if comment.pull_request:
559 515 action = 'repo.pull_request.comment.edit'
560 516 else:
561 517 action = 'repo.commit.comment.edit'
562 518
563 519 comment_data = comment.get_api_data()
564 520 comment_data['old_comment_text'] = old_comment_text
565 521 self._log_audit_action(
566 522 action, {'data': comment_data}, auth_user, comment)
567 523
568 524 return comment_history
569 525
570 526 def delete(self, comment, auth_user):
571 527 """
572 528 Deletes given comment
573 529 """
574 530 comment = self.__get_commit_comment(comment)
575 531 old_data = comment.get_api_data()
576 532 Session().delete(comment)
577 533
578 534 if comment.pull_request:
579 535 action = 'repo.pull_request.comment.delete'
580 536 else:
581 537 action = 'repo.commit.comment.delete'
582 538
583 539 self._log_audit_action(
584 540 action, {'old_data': old_data}, auth_user, comment)
585 541
586 542 return comment
587 543
588 544 def get_all_comments(self, repo_id, revision=None, pull_request=None):
589 545 q = ChangesetComment.query()\
590 546 .filter(ChangesetComment.repo_id == repo_id)
591 547 if revision:
592 548 q = q.filter(ChangesetComment.revision == revision)
593 549 elif pull_request:
594 550 pull_request = self.__get_pull_request(pull_request)
595 551 q = q.filter(ChangesetComment.pull_request == pull_request)
596 552 else:
597 553 raise Exception('Please specify commit or pull_request')
598 554 q = q.order_by(ChangesetComment.created_on)
599 555 return q.all()
600 556
601 557 def get_url(self, comment, request=None, permalink=False, anchor=None):
602 558 if not request:
603 559 request = get_current_request()
604 560
605 561 comment = self.__get_commit_comment(comment)
606 562 if anchor is None:
607 563 anchor = 'comment-{}'.format(comment.comment_id)
608 564
609 565 if comment.pull_request:
610 566 pull_request = comment.pull_request
611 567 if permalink:
612 568 return request.route_url(
613 569 'pull_requests_global',
614 570 pull_request_id=pull_request.pull_request_id,
615 571 _anchor=anchor)
616 572 else:
617 573 return request.route_url(
618 574 'pullrequest_show',
619 575 repo_name=safe_str(pull_request.target_repo.repo_name),
620 576 pull_request_id=pull_request.pull_request_id,
621 577 _anchor=anchor)
622 578
623 579 else:
624 580 repo = comment.repo
625 581 commit_id = comment.revision
626 582
627 583 if permalink:
628 584 return request.route_url(
629 585 'repo_commit', repo_name=safe_str(repo.repo_id),
630 586 commit_id=commit_id,
631 587 _anchor=anchor)
632 588
633 589 else:
634 590 return request.route_url(
635 591 'repo_commit', repo_name=safe_str(repo.repo_name),
636 592 commit_id=commit_id,
637 593 _anchor=anchor)
638 594
639 595 def get_comments(self, repo_id, revision=None, pull_request=None):
640 596 """
641 597 Gets main comments based on revision or pull_request_id
642 598
643 599 :param repo_id:
644 600 :param revision:
645 601 :param pull_request:
646 602 """
647 603
648 604 q = ChangesetComment.query()\
649 605 .filter(ChangesetComment.repo_id == repo_id)\
650 606 .filter(ChangesetComment.line_no == None)\
651 607 .filter(ChangesetComment.f_path == None)
652 608 if revision:
653 609 q = q.filter(ChangesetComment.revision == revision)
654 610 elif pull_request:
655 611 pull_request = self.__get_pull_request(pull_request)
656 612 q = q.filter(ChangesetComment.pull_request == pull_request)
657 613 else:
658 614 raise Exception('Please specify commit or pull_request')
659 615 q = q.order_by(ChangesetComment.created_on)
660 616 return q.all()
661 617
662 618 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
663 619 q = self._get_inline_comments_query(repo_id, revision, pull_request)
664 620 return self._group_comments_by_path_and_line_number(q)
665 621
666 622 def get_inline_comments_as_list(self, inline_comments, skip_outdated=True,
667 623 version=None):
668 624 inline_comms = []
669 625 for fname, per_line_comments in inline_comments.iteritems():
670 626 for lno, comments in per_line_comments.iteritems():
671 627 for comm in comments:
672 628 if not comm.outdated_at_version(version) and skip_outdated:
673 629 inline_comms.append(comm)
674 630
675 631 return inline_comms
676 632
677 633 def get_outdated_comments(self, repo_id, pull_request):
678 634 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
679 635 # of a pull request.
680 636 q = self._all_inline_comments_of_pull_request(pull_request)
681 637 q = q.filter(
682 638 ChangesetComment.display_state ==
683 639 ChangesetComment.COMMENT_OUTDATED
684 640 ).order_by(ChangesetComment.comment_id.asc())
685 641
686 642 return self._group_comments_by_path_and_line_number(q)
687 643
688 644 def _get_inline_comments_query(self, repo_id, revision, pull_request):
689 645 # TODO: johbo: Split this into two methods: One for PR and one for
690 646 # commit.
691 647 if revision:
692 648 q = Session().query(ChangesetComment).filter(
693 649 ChangesetComment.repo_id == repo_id,
694 650 ChangesetComment.line_no != null(),
695 651 ChangesetComment.f_path != null(),
696 652 ChangesetComment.revision == revision)
697 653
698 654 elif pull_request:
699 655 pull_request = self.__get_pull_request(pull_request)
700 656 if not CommentsModel.use_outdated_comments(pull_request):
701 657 q = self._visible_inline_comments_of_pull_request(pull_request)
702 658 else:
703 659 q = self._all_inline_comments_of_pull_request(pull_request)
704 660
705 661 else:
706 662 raise Exception('Please specify commit or pull_request_id')
707 663 q = q.order_by(ChangesetComment.comment_id.asc())
708 664 return q
709 665
710 666 def _group_comments_by_path_and_line_number(self, q):
711 667 comments = q.all()
712 668 paths = collections.defaultdict(lambda: collections.defaultdict(list))
713 669 for co in comments:
714 670 paths[co.f_path][co.line_no].append(co)
715 671 return paths
716 672
717 673 @classmethod
718 674 def needed_extra_diff_context(cls):
719 675 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
720 676
721 677 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
722 678 if not CommentsModel.use_outdated_comments(pull_request):
723 679 return
724 680
725 681 comments = self._visible_inline_comments_of_pull_request(pull_request)
726 682 comments_to_outdate = comments.all()
727 683
728 684 for comment in comments_to_outdate:
729 685 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
730 686
731 687 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
732 688 diff_line = _parse_comment_line_number(comment.line_no)
733 689
734 690 try:
735 691 old_context = old_diff_proc.get_context_of_line(
736 692 path=comment.f_path, diff_line=diff_line)
737 693 new_context = new_diff_proc.get_context_of_line(
738 694 path=comment.f_path, diff_line=diff_line)
739 695 except (diffs.LineNotInDiffException,
740 696 diffs.FileNotInDiffException):
741 697 comment.display_state = ChangesetComment.COMMENT_OUTDATED
742 698 return
743 699
744 700 if old_context == new_context:
745 701 return
746 702
747 703 if self._should_relocate_diff_line(diff_line):
748 704 new_diff_lines = new_diff_proc.find_context(
749 705 path=comment.f_path, context=old_context,
750 706 offset=self.DIFF_CONTEXT_BEFORE)
751 707 if not new_diff_lines:
752 708 comment.display_state = ChangesetComment.COMMENT_OUTDATED
753 709 else:
754 710 new_diff_line = self._choose_closest_diff_line(
755 711 diff_line, new_diff_lines)
756 712 comment.line_no = _diff_to_comment_line_number(new_diff_line)
757 713 else:
758 714 comment.display_state = ChangesetComment.COMMENT_OUTDATED
759 715
760 716 def _should_relocate_diff_line(self, diff_line):
761 717 """
762 718 Checks if relocation shall be tried for the given `diff_line`.
763 719
764 720 If a comment points into the first lines, then we can have a situation
765 721 that after an update another line has been added on top. In this case
766 722 we would find the context still and move the comment around. This
767 723 would be wrong.
768 724 """
769 725 should_relocate = (
770 726 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
771 727 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
772 728 return should_relocate
773 729
774 730 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
775 731 candidate = new_diff_lines[0]
776 732 best_delta = _diff_line_delta(diff_line, candidate)
777 733 for new_diff_line in new_diff_lines[1:]:
778 734 delta = _diff_line_delta(diff_line, new_diff_line)
779 735 if delta < best_delta:
780 736 candidate = new_diff_line
781 737 best_delta = delta
782 738 return candidate
783 739
784 740 def _visible_inline_comments_of_pull_request(self, pull_request):
785 741 comments = self._all_inline_comments_of_pull_request(pull_request)
786 742 comments = comments.filter(
787 743 coalesce(ChangesetComment.display_state, '') !=
788 744 ChangesetComment.COMMENT_OUTDATED)
789 745 return comments
790 746
791 747 def _all_inline_comments_of_pull_request(self, pull_request):
792 748 comments = Session().query(ChangesetComment)\
793 749 .filter(ChangesetComment.line_no != None)\
794 750 .filter(ChangesetComment.f_path != None)\
795 751 .filter(ChangesetComment.pull_request == pull_request)
796 752 return comments
797 753
798 754 def _all_general_comments_of_pull_request(self, pull_request):
799 755 comments = Session().query(ChangesetComment)\
800 756 .filter(ChangesetComment.line_no == None)\
801 757 .filter(ChangesetComment.f_path == None)\
802 758 .filter(ChangesetComment.pull_request == pull_request)
803 759
804 760 return comments
805 761
806 762 @staticmethod
807 763 def use_outdated_comments(pull_request):
808 764 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
809 765 settings = settings_model.get_general_settings()
810 766 return settings.get('rhodecode_use_outdated_comments', False)
811 767
812 768 def trigger_commit_comment_hook(self, repo, user, action, data=None):
813 769 repo = self._get_repo(repo)
814 770 target_scm = repo.scm_instance()
815 771 if action == 'create':
816 772 trigger_hook = hooks_utils.trigger_comment_commit_hooks
817 773 elif action == 'edit':
818 774 trigger_hook = hooks_utils.trigger_comment_commit_edit_hooks
819 775 else:
820 776 return
821 777
822 778 log.debug('Handling repo %s trigger_commit_comment_hook with action %s: %s',
823 779 repo, action, trigger_hook)
824 780 trigger_hook(
825 781 username=user.username,
826 782 repo_name=repo.repo_name,
827 783 repo_type=target_scm.alias,
828 784 repo=repo,
829 785 data=data)
830 786
831 787
832 788 def _parse_comment_line_number(line_no):
833 789 """
834 790 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
835 791 """
836 792 old_line = None
837 793 new_line = None
838 794 if line_no.startswith('o'):
839 795 old_line = int(line_no[1:])
840 796 elif line_no.startswith('n'):
841 797 new_line = int(line_no[1:])
842 798 else:
843 799 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
844 800 return diffs.DiffLineNumber(old_line, new_line)
845 801
846 802
847 803 def _diff_to_comment_line_number(diff_line):
848 804 if diff_line.new is not None:
849 805 return u'n{}'.format(diff_line.new)
850 806 elif diff_line.old is not None:
851 807 return u'o{}'.format(diff_line.old)
852 808 return u''
853 809
854 810
855 811 def _diff_line_delta(a, b):
856 812 if None not in (a.new, b.new):
857 813 return abs(a.new - b.new)
858 814 elif None not in (a.old, b.old):
859 815 return abs(a.old - b.old)
860 816 else:
861 817 raise ValueError(
862 818 "Cannot compute delta between {} and {}".format(a, b))
General Comments 0
You need to be logged in to leave comments. Login now