##// END OF EJS Templates
emails: added new tags to status sent...
marcink -
r548:1e26c289 default
parent child Browse files
Show More
@@ -1,633 +1,635 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2016 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
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 has_repo_permissions, resolve_ref_or_error)
29 29 from rhodecode.lib.auth import (HasRepoPermissionAnyApi)
30 30 from rhodecode.lib.base import vcs_operation_context
31 31 from rhodecode.lib.utils2 import str2bool
32 32 from rhodecode.model.changeset_status import ChangesetStatusModel
33 33 from rhodecode.model.comment import ChangesetCommentsModel
34 34 from rhodecode.model.db import Session, ChangesetStatus
35 35 from rhodecode.model.pull_request import PullRequestModel
36 36 from rhodecode.model.settings import SettingsModel
37 37
38 38 log = logging.getLogger(__name__)
39 39
40 40
41 41 @jsonrpc_method()
42 42 def get_pull_request(request, apiuser, repoid, pullrequestid):
43 43 """
44 44 Get a pull request based on the given ID.
45 45
46 46 :param apiuser: This is filled automatically from the |authtoken|.
47 47 :type apiuser: AuthUser
48 48 :param repoid: Repository name or repository ID from where the pull
49 49 request was opened.
50 50 :type repoid: str or int
51 51 :param pullrequestid: ID of the requested pull request.
52 52 :type pullrequestid: int
53 53
54 54 Example output:
55 55
56 56 .. code-block:: bash
57 57
58 58 "id": <id_given_in_input>,
59 59 "result":
60 60 {
61 61 "pull_request_id": "<pull_request_id>",
62 62 "url": "<url>",
63 63 "title": "<title>",
64 64 "description": "<description>",
65 65 "status" : "<status>",
66 66 "created_on": "<date_time_created>",
67 67 "updated_on": "<date_time_updated>",
68 68 "commit_ids": [
69 69 ...
70 70 "<commit_id>",
71 71 "<commit_id>",
72 72 ...
73 73 ],
74 74 "review_status": "<review_status>",
75 75 "mergeable": {
76 76 "status": "<bool>",
77 77 "message": "<message>",
78 78 },
79 79 "source": {
80 80 "clone_url": "<clone_url>",
81 81 "repository": "<repository_name>",
82 82 "reference":
83 83 {
84 84 "name": "<name>",
85 85 "type": "<type>",
86 86 "commit_id": "<commit_id>",
87 87 }
88 88 },
89 89 "target": {
90 90 "clone_url": "<clone_url>",
91 91 "repository": "<repository_name>",
92 92 "reference":
93 93 {
94 94 "name": "<name>",
95 95 "type": "<type>",
96 96 "commit_id": "<commit_id>",
97 97 }
98 98 },
99 99 "author": <user_obj>,
100 100 "reviewers": [
101 101 ...
102 102 {
103 103 "user": "<user_obj>",
104 104 "review_status": "<review_status>",
105 105 }
106 106 ...
107 107 ]
108 108 },
109 109 "error": null
110 110 """
111 111 get_repo_or_error(repoid)
112 112 pull_request = get_pull_request_or_error(pullrequestid)
113 113 if not PullRequestModel().check_user_read(
114 114 pull_request, apiuser, api=True):
115 115 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
116 116 data = pull_request.get_api_data()
117 117 return data
118 118
119 119
120 120 @jsonrpc_method()
121 121 def get_pull_requests(request, apiuser, repoid, status=Optional('new')):
122 122 """
123 123 Get all pull requests from the repository specified in `repoid`.
124 124
125 125 :param apiuser: This is filled automatically from the |authtoken|.
126 126 :type apiuser: AuthUser
127 127 :param repoid: Repository name or repository ID.
128 128 :type repoid: str or int
129 129 :param status: Only return pull requests with the specified status.
130 130 Valid options are.
131 131 * ``new`` (default)
132 132 * ``open``
133 133 * ``closed``
134 134 :type status: str
135 135
136 136 Example output:
137 137
138 138 .. code-block:: bash
139 139
140 140 "id": <id_given_in_input>,
141 141 "result":
142 142 [
143 143 ...
144 144 {
145 145 "pull_request_id": "<pull_request_id>",
146 146 "url": "<url>",
147 147 "title" : "<title>",
148 148 "description": "<description>",
149 149 "status": "<status>",
150 150 "created_on": "<date_time_created>",
151 151 "updated_on": "<date_time_updated>",
152 152 "commit_ids": [
153 153 ...
154 154 "<commit_id>",
155 155 "<commit_id>",
156 156 ...
157 157 ],
158 158 "review_status": "<review_status>",
159 159 "mergeable": {
160 160 "status": "<bool>",
161 161 "message: "<message>",
162 162 },
163 163 "source": {
164 164 "clone_url": "<clone_url>",
165 165 "reference":
166 166 {
167 167 "name": "<name>",
168 168 "type": "<type>",
169 169 "commit_id": "<commit_id>",
170 170 }
171 171 },
172 172 "target": {
173 173 "clone_url": "<clone_url>",
174 174 "reference":
175 175 {
176 176 "name": "<name>",
177 177 "type": "<type>",
178 178 "commit_id": "<commit_id>",
179 179 }
180 180 },
181 181 "author": <user_obj>,
182 182 "reviewers": [
183 183 ...
184 184 {
185 185 "user": "<user_obj>",
186 186 "review_status": "<review_status>",
187 187 }
188 188 ...
189 189 ]
190 190 }
191 191 ...
192 192 ],
193 193 "error": null
194 194
195 195 """
196 196 repo = get_repo_or_error(repoid)
197 197 if not has_superadmin_permission(apiuser):
198 198 _perms = (
199 199 'repository.admin', 'repository.write', 'repository.read',)
200 200 has_repo_permissions(apiuser, repoid, repo, _perms)
201 201
202 202 status = Optional.extract(status)
203 203 pull_requests = PullRequestModel().get_all(repo, statuses=[status])
204 204 data = [pr.get_api_data() for pr in pull_requests]
205 205 return data
206 206
207 207
208 208 @jsonrpc_method()
209 209 def merge_pull_request(request, apiuser, repoid, pullrequestid,
210 210 userid=Optional(OAttr('apiuser'))):
211 211 """
212 212 Merge the pull request specified by `pullrequestid` into its target
213 213 repository.
214 214
215 215 :param apiuser: This is filled automatically from the |authtoken|.
216 216 :type apiuser: AuthUser
217 217 :param repoid: The Repository name or repository ID of the
218 218 target repository to which the |pr| is to be merged.
219 219 :type repoid: str or int
220 220 :param pullrequestid: ID of the pull request which shall be merged.
221 221 :type pullrequestid: int
222 222 :param userid: Merge the pull request as this user.
223 223 :type userid: Optional(str or int)
224 224
225 225 Example output:
226 226
227 227 .. code-block:: bash
228 228
229 229 "id": <id_given_in_input>,
230 230 "result":
231 231 {
232 232 "executed": "<bool>",
233 233 "failure_reason": "<int>",
234 234 "merge_commit_id": "<merge_commit_id>",
235 235 "possible": "<bool>"
236 236 },
237 237 "error": null
238 238
239 239 """
240 240 repo = get_repo_or_error(repoid)
241 241 if not isinstance(userid, Optional):
242 242 if (has_superadmin_permission(apiuser) or
243 243 HasRepoPermissionAnyApi('repository.admin')(
244 244 user=apiuser, repo_name=repo.repo_name)):
245 245 apiuser = get_user_or_error(userid)
246 246 else:
247 247 raise JSONRPCError('userid is not the same as your user')
248 248
249 249 pull_request = get_pull_request_or_error(pullrequestid)
250 250 if not PullRequestModel().check_user_merge(
251 251 pull_request, apiuser, api=True):
252 252 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
253 253 if pull_request.is_closed():
254 254 raise JSONRPCError(
255 255 'pull request `%s` merge failed, pull request is closed' % (
256 256 pullrequestid,))
257 257
258 258 target_repo = pull_request.target_repo
259 259 extras = vcs_operation_context(
260 260 request.environ, repo_name=target_repo.repo_name,
261 261 username=apiuser.username, action='push',
262 262 scm=target_repo.repo_type)
263 263 data = PullRequestModel().merge(pull_request, apiuser, extras=extras)
264 264 if data.executed:
265 265 PullRequestModel().close_pull_request(
266 266 pull_request.pull_request_id, apiuser)
267 267
268 268 Session().commit()
269 269 return data
270 270
271 271
272 272 @jsonrpc_method()
273 273 def close_pull_request(request, apiuser, repoid, pullrequestid,
274 274 userid=Optional(OAttr('apiuser'))):
275 275 """
276 276 Close the pull request specified by `pullrequestid`.
277 277
278 278 :param apiuser: This is filled automatically from the |authtoken|.
279 279 :type apiuser: AuthUser
280 280 :param repoid: Repository name or repository ID to which the pull
281 281 request belongs.
282 282 :type repoid: str or int
283 283 :param pullrequestid: ID of the pull request to be closed.
284 284 :type pullrequestid: int
285 285 :param userid: Close the pull request as this user.
286 286 :type userid: Optional(str or int)
287 287
288 288 Example output:
289 289
290 290 .. code-block:: bash
291 291
292 292 "id": <id_given_in_input>,
293 293 "result":
294 294 {
295 295 "pull_request_id": "<int>",
296 296 "closed": "<bool>"
297 297 },
298 298 "error": null
299 299
300 300 """
301 301 repo = get_repo_or_error(repoid)
302 302 if not isinstance(userid, Optional):
303 303 if (has_superadmin_permission(apiuser) or
304 304 HasRepoPermissionAnyApi('repository.admin')(
305 305 user=apiuser, repo_name=repo.repo_name)):
306 306 apiuser = get_user_or_error(userid)
307 307 else:
308 308 raise JSONRPCError('userid is not the same as your user')
309 309
310 310 pull_request = get_pull_request_or_error(pullrequestid)
311 311 if not PullRequestModel().check_user_update(
312 312 pull_request, apiuser, api=True):
313 313 raise JSONRPCError(
314 314 'pull request `%s` close failed, no permission to close.' % (
315 315 pullrequestid,))
316 316 if pull_request.is_closed():
317 317 raise JSONRPCError(
318 318 'pull request `%s` is already closed' % (pullrequestid,))
319 319
320 320 PullRequestModel().close_pull_request(
321 321 pull_request.pull_request_id, apiuser)
322 322 Session().commit()
323 323 data = {
324 324 'pull_request_id': pull_request.pull_request_id,
325 325 'closed': True,
326 326 }
327 327 return data
328 328
329 329
330 330 @jsonrpc_method()
331 331 def comment_pull_request(request, apiuser, repoid, pullrequestid,
332 332 message=Optional(None), status=Optional(None),
333 333 userid=Optional(OAttr('apiuser'))):
334 334 """
335 335 Comment on the pull request specified with the `pullrequestid`,
336 336 in the |repo| specified by the `repoid`, and optionally change the
337 337 review status.
338 338
339 339 :param apiuser: This is filled automatically from the |authtoken|.
340 340 :type apiuser: AuthUser
341 341 :param repoid: The repository name or repository ID.
342 342 :type repoid: str or int
343 343 :param pullrequestid: The pull request ID.
344 344 :type pullrequestid: int
345 345 :param message: The text content of the comment.
346 346 :type message: str
347 347 :param status: (**Optional**) Set the approval status of the pull
348 348 request. Valid options are:
349 349 * not_reviewed
350 350 * approved
351 351 * rejected
352 352 * under_review
353 353 :type status: str
354 354 :param userid: Comment on the pull request as this user
355 355 :type userid: Optional(str or int)
356 356
357 357 Example output:
358 358
359 359 .. code-block:: bash
360 360
361 361 id : <id_given_in_input>
362 362 result :
363 363 {
364 364 "pull_request_id": "<Integer>",
365 365 "comment_id": "<Integer>"
366 366 }
367 367 error : null
368 368 """
369 369 repo = get_repo_or_error(repoid)
370 370 if not isinstance(userid, Optional):
371 371 if (has_superadmin_permission(apiuser) or
372 372 HasRepoPermissionAnyApi('repository.admin')(
373 373 user=apiuser, repo_name=repo.repo_name)):
374 374 apiuser = get_user_or_error(userid)
375 375 else:
376 376 raise JSONRPCError('userid is not the same as your user')
377 377
378 378 pull_request = get_pull_request_or_error(pullrequestid)
379 379 if not PullRequestModel().check_user_read(
380 380 pull_request, apiuser, api=True):
381 381 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
382 382 message = Optional.extract(message)
383 383 status = Optional.extract(status)
384 384 if not message and not status:
385 385 raise JSONRPCError('message and status parameter missing')
386 386
387 387 if (status not in (st[0] for st in ChangesetStatus.STATUSES) and
388 388 status is not None):
389 389 raise JSONRPCError('unknown comment status`%s`' % status)
390 390
391 391 allowed_to_change_status = PullRequestModel().check_user_change_status(
392 392 pull_request, apiuser)
393 393 text = message
394 394 if status and allowed_to_change_status:
395 395 st_message = (('Status change %(transition_icon)s %(status)s')
396 396 % {'transition_icon': '>',
397 397 'status': ChangesetStatus.get_status_lbl(status)})
398 398 text = message or st_message
399 399
400 400 rc_config = SettingsModel().get_all_settings()
401 401 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
402 402 comment = ChangesetCommentsModel().create(
403 403 text=text,
404 404 repo=pull_request.target_repo.repo_id,
405 405 user=apiuser.user_id,
406 406 pull_request=pull_request.pull_request_id,
407 407 f_path=None,
408 408 line_no=None,
409 409 status_change=(ChangesetStatus.get_status_lbl(status)
410 410 if status and allowed_to_change_status else None),
411 status_change_type=(status
412 if status and allowed_to_change_status else None),
411 413 closing_pr=False,
412 414 renderer=renderer
413 415 )
414 416
415 417 if allowed_to_change_status and status:
416 418 ChangesetStatusModel().set_status(
417 419 pull_request.target_repo.repo_id,
418 420 status,
419 421 apiuser.user_id,
420 422 comment,
421 423 pull_request=pull_request.pull_request_id
422 424 )
423 425 Session().flush()
424 426
425 427 Session().commit()
426 428 data = {
427 429 'pull_request_id': pull_request.pull_request_id,
428 430 'comment_id': comment.comment_id,
429 431 'status': status
430 432 }
431 433 return data
432 434
433 435
434 436 @jsonrpc_method()
435 437 def create_pull_request(
436 438 request, apiuser, source_repo, target_repo, source_ref, target_ref,
437 439 title, description=Optional(''), reviewers=Optional(None)):
438 440 """
439 441 Creates a new pull request.
440 442
441 443 Accepts refs in the following formats:
442 444
443 445 * branch:<branch_name>:<sha>
444 446 * branch:<branch_name>
445 447 * bookmark:<bookmark_name>:<sha> (Mercurial only)
446 448 * bookmark:<bookmark_name> (Mercurial only)
447 449
448 450 :param apiuser: This is filled automatically from the |authtoken|.
449 451 :type apiuser: AuthUser
450 452 :param source_repo: Set the source repository name.
451 453 :type source_repo: str
452 454 :param target_repo: Set the target repository name.
453 455 :type target_repo: str
454 456 :param source_ref: Set the source ref name.
455 457 :type source_ref: str
456 458 :param target_ref: Set the target ref name.
457 459 :type target_ref: str
458 460 :param title: Set the pull request title.
459 461 :type title: str
460 462 :param description: Set the pull request description.
461 463 :type description: Optional(str)
462 464 :param reviewers: Set the new pull request reviewers list.
463 465 :type reviewers: Optional(list)
464 466 """
465 467 source = get_repo_or_error(source_repo)
466 468 target = get_repo_or_error(target_repo)
467 469 if not has_superadmin_permission(apiuser):
468 470 _perms = ('repository.admin', 'repository.write', 'repository.read',)
469 471 has_repo_permissions(apiuser, source_repo, source, _perms)
470 472
471 473 full_source_ref = resolve_ref_or_error(source_ref, source)
472 474 full_target_ref = resolve_ref_or_error(target_ref, target)
473 475 source_commit = get_commit_or_error(full_source_ref, source)
474 476 target_commit = get_commit_or_error(full_target_ref, target)
475 477 source_scm = source.scm_instance()
476 478 target_scm = target.scm_instance()
477 479
478 480 commit_ranges = target_scm.compare(
479 481 target_commit.raw_id, source_commit.raw_id, source_scm,
480 482 merge=True, pre_load=[])
481 483
482 484 ancestor = target_scm.get_common_ancestor(
483 485 target_commit.raw_id, source_commit.raw_id, source_scm)
484 486
485 487 if not commit_ranges:
486 488 raise JSONRPCError('no commits found')
487 489
488 490 if not ancestor:
489 491 raise JSONRPCError('no common ancestor found')
490 492
491 493 reviewer_names = Optional.extract(reviewers) or []
492 494 if not isinstance(reviewer_names, list):
493 495 raise JSONRPCError('reviewers should be specified as a list')
494 496
495 497 reviewer_users = [get_user_or_error(n) for n in reviewer_names]
496 498 reviewer_ids = [u.user_id for u in reviewer_users]
497 499
498 500 pull_request_model = PullRequestModel()
499 501 pull_request = pull_request_model.create(
500 502 created_by=apiuser.user_id,
501 503 source_repo=source_repo,
502 504 source_ref=full_source_ref,
503 505 target_repo=target_repo,
504 506 target_ref=full_target_ref,
505 507 revisions=reversed(
506 508 [commit.raw_id for commit in reversed(commit_ranges)]),
507 509 reviewers=reviewer_ids,
508 510 title=title,
509 511 description=Optional.extract(description)
510 512 )
511 513
512 514 Session().commit()
513 515 data = {
514 516 'msg': 'Created new pull request `{}`'.format(title),
515 517 'pull_request_id': pull_request.pull_request_id,
516 518 }
517 519 return data
518 520
519 521
520 522 @jsonrpc_method()
521 523 def update_pull_request(
522 524 request, apiuser, repoid, pullrequestid, title=Optional(''),
523 525 description=Optional(''), reviewers=Optional(None),
524 526 update_commits=Optional(None), close_pull_request=Optional(None)):
525 527 """
526 528 Updates a pull request.
527 529
528 530 :param apiuser: This is filled automatically from the |authtoken|.
529 531 :type apiuser: AuthUser
530 532 :param repoid: The repository name or repository ID.
531 533 :type repoid: str or int
532 534 :param pullrequestid: The pull request ID.
533 535 :type pullrequestid: int
534 536 :param title: Set the pull request title.
535 537 :type title: str
536 538 :param description: Update pull request description.
537 539 :type description: Optional(str)
538 540 :param reviewers: Update pull request reviewers list with new value.
539 541 :type reviewers: Optional(list)
540 542 :param update_commits: Trigger update of commits for this pull request
541 543 :type: update_commits: Optional(bool)
542 544 :param close_pull_request: Close this pull request with rejected state
543 545 :type: close_pull_request: Optional(bool)
544 546
545 547 Example output:
546 548
547 549 .. code-block:: bash
548 550
549 551 id : <id_given_in_input>
550 552 result :
551 553 {
552 554 "msg": "Updated pull request `63`",
553 555 "pull_request": <pull_request_object>,
554 556 "updated_reviewers": {
555 557 "added": [
556 558 "username"
557 559 ],
558 560 "removed": []
559 561 },
560 562 "updated_commits": {
561 563 "added": [
562 564 "<sha1_hash>"
563 565 ],
564 566 "common": [
565 567 "<sha1_hash>",
566 568 "<sha1_hash>",
567 569 ],
568 570 "removed": []
569 571 }
570 572 }
571 573 error : null
572 574 """
573 575
574 576 repo = get_repo_or_error(repoid)
575 577 pull_request = get_pull_request_or_error(pullrequestid)
576 578 if not PullRequestModel().check_user_update(
577 579 pull_request, apiuser, api=True):
578 580 raise JSONRPCError(
579 581 'pull request `%s` update failed, no permission to update.' % (
580 582 pullrequestid,))
581 583 if pull_request.is_closed():
582 584 raise JSONRPCError(
583 585 'pull request `%s` update failed, pull request is closed' % (
584 586 pullrequestid,))
585 587
586 588 reviewer_names = Optional.extract(reviewers) or []
587 589 if not isinstance(reviewer_names, list):
588 590 raise JSONRPCError('reviewers should be specified as a list')
589 591
590 592 reviewer_users = [get_user_or_error(n) for n in reviewer_names]
591 593 reviewer_ids = [u.user_id for u in reviewer_users]
592 594
593 595 title = Optional.extract(title)
594 596 description = Optional.extract(description)
595 597 if title or description:
596 598 PullRequestModel().edit(
597 599 pull_request, title or pull_request.title,
598 600 description or pull_request.description)
599 601 Session().commit()
600 602
601 603 commit_changes = {"added": [], "common": [], "removed": []}
602 604 if str2bool(Optional.extract(update_commits)):
603 605 if PullRequestModel().has_valid_update_type(pull_request):
604 606 _version, _commit_changes = PullRequestModel().update_commits(
605 607 pull_request)
606 608 commit_changes = _commit_changes or commit_changes
607 609 Session().commit()
608 610
609 611 reviewers_changes = {"added": [], "removed": []}
610 612 if reviewer_ids:
611 613 added_reviewers, removed_reviewers = \
612 614 PullRequestModel().update_reviewers(pull_request, reviewer_ids)
613 615
614 616 reviewers_changes['added'] = sorted(
615 617 [get_user_or_error(n).username for n in added_reviewers])
616 618 reviewers_changes['removed'] = sorted(
617 619 [get_user_or_error(n).username for n in removed_reviewers])
618 620 Session().commit()
619 621
620 622 if str2bool(Optional.extract(close_pull_request)):
621 623 PullRequestModel().close_pull_request_with_comment(
622 624 pull_request, apiuser, repo)
623 625 Session().commit()
624 626
625 627 data = {
626 628 'msg': 'Updated pull request `{}`'.format(
627 629 pull_request.pull_request_id),
628 630 'pull_request': pull_request.get_api_data(),
629 631 'updated_commits': commit_changes,
630 632 'updated_reviewers': reviewers_changes
631 633 }
632 634 return data
633 635
@@ -1,1886 +1,1888 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2016 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 colander
25 25
26 26 from rhodecode import BACKENDS
27 27 from rhodecode.api import jsonrpc_method, JSONRPCError, JSONRPCForbidden, json
28 28 from rhodecode.api.utils import (
29 29 has_superadmin_permission, Optional, OAttr, get_repo_or_error,
30 30 get_user_group_or_error, get_user_or_error, has_repo_permissions,
31 31 get_perm_or_error, store_update, get_repo_group_or_error, parse_args,
32 32 get_origin, build_commit_data)
33 33 from rhodecode.lib.auth import (
34 34 HasPermissionAnyApi, HasRepoGroupPermissionAnyApi,
35 35 HasUserGroupPermissionAnyApi)
36 36 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError
37 37 from rhodecode.lib.utils import map_groups
38 38 from rhodecode.lib.utils2 import str2bool, time_to_datetime
39 39 from rhodecode.model.changeset_status import ChangesetStatusModel
40 40 from rhodecode.model.comment import ChangesetCommentsModel
41 41 from rhodecode.model.db import (
42 42 Session, ChangesetStatus, RepositoryField, Repository)
43 43 from rhodecode.model.repo import RepoModel
44 44 from rhodecode.model.repo_group import RepoGroupModel
45 45 from rhodecode.model.scm import ScmModel, RepoList
46 46 from rhodecode.model.settings import SettingsModel, VcsSettingsModel
47 47 from rhodecode.model.validation_schema.schemas import repo_schema
48 48
49 49 log = logging.getLogger(__name__)
50 50
51 51
52 52 @jsonrpc_method()
53 53 def get_repo(request, apiuser, repoid, cache=Optional(True)):
54 54 """
55 55 Gets an existing repository by its name or repository_id.
56 56
57 57 The members section so the output returns users groups or users
58 58 associated with that repository.
59 59
60 60 This command can only be run using an |authtoken| with admin rights,
61 61 or users with at least read rights to the |repo|.
62 62
63 63 :param apiuser: This is filled automatically from the |authtoken|.
64 64 :type apiuser: AuthUser
65 65 :param repoid: The repository name or repository id.
66 66 :type repoid: str or int
67 67 :param cache: use the cached value for last changeset
68 68 :type: cache: Optional(bool)
69 69
70 70 Example output:
71 71
72 72 .. code-block:: bash
73 73
74 74 {
75 75 "error": null,
76 76 "id": <repo_id>,
77 77 "result": {
78 78 "clone_uri": null,
79 79 "created_on": "timestamp",
80 80 "description": "repo description",
81 81 "enable_downloads": false,
82 82 "enable_locking": false,
83 83 "enable_statistics": false,
84 84 "followers": [
85 85 {
86 86 "active": true,
87 87 "admin": false,
88 88 "api_key": "****************************************",
89 89 "api_keys": [
90 90 "****************************************"
91 91 ],
92 92 "email": "user@example.com",
93 93 "emails": [
94 94 "user@example.com"
95 95 ],
96 96 "extern_name": "rhodecode",
97 97 "extern_type": "rhodecode",
98 98 "firstname": "username",
99 99 "ip_addresses": [],
100 100 "language": null,
101 101 "last_login": "2015-09-16T17:16:35.854",
102 102 "lastname": "surname",
103 103 "user_id": <user_id>,
104 104 "username": "name"
105 105 }
106 106 ],
107 107 "fork_of": "parent-repo",
108 108 "landing_rev": [
109 109 "rev",
110 110 "tip"
111 111 ],
112 112 "last_changeset": {
113 113 "author": "User <user@example.com>",
114 114 "branch": "default",
115 115 "date": "timestamp",
116 116 "message": "last commit message",
117 117 "parents": [
118 118 {
119 119 "raw_id": "commit-id"
120 120 }
121 121 ],
122 122 "raw_id": "commit-id",
123 123 "revision": <revision number>,
124 124 "short_id": "short id"
125 125 },
126 126 "lock_reason": null,
127 127 "locked_by": null,
128 128 "locked_date": null,
129 129 "members": [
130 130 {
131 131 "name": "super-admin-name",
132 132 "origin": "super-admin",
133 133 "permission": "repository.admin",
134 134 "type": "user"
135 135 },
136 136 {
137 137 "name": "owner-name",
138 138 "origin": "owner",
139 139 "permission": "repository.admin",
140 140 "type": "user"
141 141 },
142 142 {
143 143 "name": "user-group-name",
144 144 "origin": "permission",
145 145 "permission": "repository.write",
146 146 "type": "user_group"
147 147 }
148 148 ],
149 149 "owner": "owner-name",
150 150 "permissions": [
151 151 {
152 152 "name": "super-admin-name",
153 153 "origin": "super-admin",
154 154 "permission": "repository.admin",
155 155 "type": "user"
156 156 },
157 157 {
158 158 "name": "owner-name",
159 159 "origin": "owner",
160 160 "permission": "repository.admin",
161 161 "type": "user"
162 162 },
163 163 {
164 164 "name": "user-group-name",
165 165 "origin": "permission",
166 166 "permission": "repository.write",
167 167 "type": "user_group"
168 168 }
169 169 ],
170 170 "private": true,
171 171 "repo_id": 676,
172 172 "repo_name": "user-group/repo-name",
173 173 "repo_type": "hg"
174 174 }
175 175 }
176 176 """
177 177
178 178 repo = get_repo_or_error(repoid)
179 179 cache = Optional.extract(cache)
180 180 include_secrets = False
181 181 if has_superadmin_permission(apiuser):
182 182 include_secrets = True
183 183 else:
184 184 # check if we have at least read permission for this repo !
185 185 _perms = (
186 186 'repository.admin', 'repository.write', 'repository.read',)
187 187 has_repo_permissions(apiuser, repoid, repo, _perms)
188 188
189 189 permissions = []
190 190 for _user in repo.permissions():
191 191 user_data = {
192 192 'name': _user.username,
193 193 'permission': _user.permission,
194 194 'origin': get_origin(_user),
195 195 'type': "user",
196 196 }
197 197 permissions.append(user_data)
198 198
199 199 for _user_group in repo.permission_user_groups():
200 200 user_group_data = {
201 201 'name': _user_group.users_group_name,
202 202 'permission': _user_group.permission,
203 203 'origin': get_origin(_user_group),
204 204 'type': "user_group",
205 205 }
206 206 permissions.append(user_group_data)
207 207
208 208 following_users = [
209 209 user.user.get_api_data(include_secrets=include_secrets)
210 210 for user in repo.followers]
211 211
212 212 if not cache:
213 213 repo.update_commit_cache()
214 214 data = repo.get_api_data(include_secrets=include_secrets)
215 215 data['members'] = permissions # TODO: this should be deprecated soon
216 216 data['permissions'] = permissions
217 217 data['followers'] = following_users
218 218 return data
219 219
220 220
221 221 @jsonrpc_method()
222 222 def get_repos(request, apiuser):
223 223 """
224 224 Lists all existing repositories.
225 225
226 226 This command can only be run using an |authtoken| with admin rights,
227 227 or users with at least read rights to |repos|.
228 228
229 229 :param apiuser: This is filled automatically from the |authtoken|.
230 230 :type apiuser: AuthUser
231 231
232 232 Example output:
233 233
234 234 .. code-block:: bash
235 235
236 236 id : <id_given_in_input>
237 237 result: [
238 238 {
239 239 "repo_id" : "<repo_id>",
240 240 "repo_name" : "<reponame>"
241 241 "repo_type" : "<repo_type>",
242 242 "clone_uri" : "<clone_uri>",
243 243 "private": : "<bool>",
244 244 "created_on" : "<datetimecreated>",
245 245 "description" : "<description>",
246 246 "landing_rev": "<landing_rev>",
247 247 "owner": "<repo_owner>",
248 248 "fork_of": "<name_of_fork_parent>",
249 249 "enable_downloads": "<bool>",
250 250 "enable_locking": "<bool>",
251 251 "enable_statistics": "<bool>",
252 252 },
253 253 ...
254 254 ]
255 255 error: null
256 256 """
257 257
258 258 include_secrets = has_superadmin_permission(apiuser)
259 259 _perms = ('repository.read', 'repository.write', 'repository.admin',)
260 260 extras = {'user': apiuser}
261 261
262 262 repo_list = RepoList(
263 263 RepoModel().get_all(), perm_set=_perms, extra_kwargs=extras)
264 264 return [repo.get_api_data(include_secrets=include_secrets)
265 265 for repo in repo_list]
266 266
267 267
268 268 @jsonrpc_method()
269 269 def get_repo_changeset(request, apiuser, repoid, revision,
270 270 details=Optional('basic')):
271 271 """
272 272 Returns information about a changeset.
273 273
274 274 Additionally parameters define the amount of details returned by
275 275 this function.
276 276
277 277 This command can only be run using an |authtoken| with admin rights,
278 278 or users with at least read rights to the |repo|.
279 279
280 280 :param apiuser: This is filled automatically from the |authtoken|.
281 281 :type apiuser: AuthUser
282 282 :param repoid: The repository name or repository id
283 283 :type repoid: str or int
284 284 :param revision: revision for which listing should be done
285 285 :type revision: str
286 286 :param details: details can be 'basic|extended|full' full gives diff
287 287 info details like the diff itself, and number of changed files etc.
288 288 :type details: Optional(str)
289 289
290 290 """
291 291 repo = get_repo_or_error(repoid)
292 292 if not has_superadmin_permission(apiuser):
293 293 _perms = (
294 294 'repository.admin', 'repository.write', 'repository.read',)
295 295 has_repo_permissions(apiuser, repoid, repo, _perms)
296 296
297 297 changes_details = Optional.extract(details)
298 298 _changes_details_types = ['basic', 'extended', 'full']
299 299 if changes_details not in _changes_details_types:
300 300 raise JSONRPCError(
301 301 'ret_type must be one of %s' % (
302 302 ','.join(_changes_details_types)))
303 303
304 304 pre_load = ['author', 'branch', 'date', 'message', 'parents',
305 305 'status', '_commit', '_file_paths']
306 306
307 307 try:
308 308 cs = repo.get_commit(commit_id=revision, pre_load=pre_load)
309 309 except TypeError as e:
310 310 raise JSONRPCError(e.message)
311 311 _cs_json = cs.__json__()
312 312 _cs_json['diff'] = build_commit_data(cs, changes_details)
313 313 if changes_details == 'full':
314 314 _cs_json['refs'] = {
315 315 'branches': [cs.branch],
316 316 'bookmarks': getattr(cs, 'bookmarks', []),
317 317 'tags': cs.tags
318 318 }
319 319 return _cs_json
320 320
321 321
322 322 @jsonrpc_method()
323 323 def get_repo_changesets(request, apiuser, repoid, start_rev, limit,
324 324 details=Optional('basic')):
325 325 """
326 326 Returns a set of commits limited by the number starting
327 327 from the `start_rev` option.
328 328
329 329 Additional parameters define the amount of details returned by this
330 330 function.
331 331
332 332 This command can only be run using an |authtoken| with admin rights,
333 333 or users with at least read rights to |repos|.
334 334
335 335 :param apiuser: This is filled automatically from the |authtoken|.
336 336 :type apiuser: AuthUser
337 337 :param repoid: The repository name or repository ID.
338 338 :type repoid: str or int
339 339 :param start_rev: The starting revision from where to get changesets.
340 340 :type start_rev: str
341 341 :param limit: Limit the number of commits to this amount
342 342 :type limit: str or int
343 343 :param details: Set the level of detail returned. Valid option are:
344 344 ``basic``, ``extended`` and ``full``.
345 345 :type details: Optional(str)
346 346
347 347 .. note::
348 348
349 349 Setting the parameter `details` to the value ``full`` is extensive
350 350 and returns details like the diff itself, and the number
351 351 of changed files.
352 352
353 353 """
354 354 repo = get_repo_or_error(repoid)
355 355 if not has_superadmin_permission(apiuser):
356 356 _perms = (
357 357 'repository.admin', 'repository.write', 'repository.read',)
358 358 has_repo_permissions(apiuser, repoid, repo, _perms)
359 359
360 360 changes_details = Optional.extract(details)
361 361 _changes_details_types = ['basic', 'extended', 'full']
362 362 if changes_details not in _changes_details_types:
363 363 raise JSONRPCError(
364 364 'ret_type must be one of %s' % (
365 365 ','.join(_changes_details_types)))
366 366
367 367 limit = int(limit)
368 368 pre_load = ['author', 'branch', 'date', 'message', 'parents',
369 369 'status', '_commit', '_file_paths']
370 370
371 371 vcs_repo = repo.scm_instance()
372 372 # SVN needs a special case to distinguish its index and commit id
373 373 if vcs_repo and vcs_repo.alias == 'svn' and (start_rev == '0'):
374 374 start_rev = vcs_repo.commit_ids[0]
375 375
376 376 try:
377 377 commits = vcs_repo.get_commits(
378 378 start_id=start_rev, pre_load=pre_load)
379 379 except TypeError as e:
380 380 raise JSONRPCError(e.message)
381 381 except Exception:
382 382 log.exception('Fetching of commits failed')
383 383 raise JSONRPCError('Error occurred during commit fetching')
384 384
385 385 ret = []
386 386 for cnt, commit in enumerate(commits):
387 387 if cnt >= limit != -1:
388 388 break
389 389 _cs_json = commit.__json__()
390 390 _cs_json['diff'] = build_commit_data(commit, changes_details)
391 391 if changes_details == 'full':
392 392 _cs_json['refs'] = {
393 393 'branches': [commit.branch],
394 394 'bookmarks': getattr(commit, 'bookmarks', []),
395 395 'tags': commit.tags
396 396 }
397 397 ret.append(_cs_json)
398 398 return ret
399 399
400 400
401 401 @jsonrpc_method()
402 402 def get_repo_nodes(request, apiuser, repoid, revision, root_path,
403 403 ret_type=Optional('all'), details=Optional('basic'),
404 404 max_file_bytes=Optional(None)):
405 405 """
406 406 Returns a list of nodes and children in a flat list for a given
407 407 path at given revision.
408 408
409 409 It's possible to specify ret_type to show only `files` or `dirs`.
410 410
411 411 This command can only be run using an |authtoken| with admin rights,
412 412 or users with at least read rights to |repos|.
413 413
414 414 :param apiuser: This is filled automatically from the |authtoken|.
415 415 :type apiuser: AuthUser
416 416 :param repoid: The repository name or repository ID.
417 417 :type repoid: str or int
418 418 :param revision: The revision for which listing should be done.
419 419 :type revision: str
420 420 :param root_path: The path from which to start displaying.
421 421 :type root_path: str
422 422 :param ret_type: Set the return type. Valid options are
423 423 ``all`` (default), ``files`` and ``dirs``.
424 424 :type ret_type: Optional(str)
425 425 :param details: Returns extended information about nodes, such as
426 426 md5, binary, and or content. The valid options are ``basic`` and
427 427 ``full``.
428 428 :type details: Optional(str)
429 429 :param max_file_bytes: Only return file content under this file size bytes
430 430 :type details: Optional(int)
431 431
432 432 Example output:
433 433
434 434 .. code-block:: bash
435 435
436 436 id : <id_given_in_input>
437 437 result: [
438 438 {
439 439 "name" : "<name>"
440 440 "type" : "<type>",
441 441 "binary": "<true|false>" (only in extended mode)
442 442 "md5" : "<md5 of file content>" (only in extended mode)
443 443 },
444 444 ...
445 445 ]
446 446 error: null
447 447 """
448 448
449 449 repo = get_repo_or_error(repoid)
450 450 if not has_superadmin_permission(apiuser):
451 451 _perms = (
452 452 'repository.admin', 'repository.write', 'repository.read',)
453 453 has_repo_permissions(apiuser, repoid, repo, _perms)
454 454
455 455 ret_type = Optional.extract(ret_type)
456 456 details = Optional.extract(details)
457 457 _extended_types = ['basic', 'full']
458 458 if details not in _extended_types:
459 459 raise JSONRPCError(
460 460 'ret_type must be one of %s' % (','.join(_extended_types)))
461 461 extended_info = False
462 462 content = False
463 463 if details == 'basic':
464 464 extended_info = True
465 465
466 466 if details == 'full':
467 467 extended_info = content = True
468 468
469 469 _map = {}
470 470 try:
471 471 # check if repo is not empty by any chance, skip quicker if it is.
472 472 _scm = repo.scm_instance()
473 473 if _scm.is_empty():
474 474 return []
475 475
476 476 _d, _f = ScmModel().get_nodes(
477 477 repo, revision, root_path, flat=False,
478 478 extended_info=extended_info, content=content,
479 479 max_file_bytes=max_file_bytes)
480 480 _map = {
481 481 'all': _d + _f,
482 482 'files': _f,
483 483 'dirs': _d,
484 484 }
485 485 return _map[ret_type]
486 486 except KeyError:
487 487 raise JSONRPCError(
488 488 'ret_type must be one of %s' % (','.join(sorted(_map.keys()))))
489 489 except Exception:
490 490 log.exception("Exception occurred while trying to get repo nodes")
491 491 raise JSONRPCError(
492 492 'failed to get repo: `%s` nodes' % repo.repo_name
493 493 )
494 494
495 495
496 496 @jsonrpc_method()
497 497 def get_repo_refs(request, apiuser, repoid):
498 498 """
499 499 Returns a dictionary of current references. It returns
500 500 bookmarks, branches, closed_branches, and tags for given repository
501 501
502 502 It's possible to specify ret_type to show only `files` or `dirs`.
503 503
504 504 This command can only be run using an |authtoken| with admin rights,
505 505 or users with at least read rights to |repos|.
506 506
507 507 :param apiuser: This is filled automatically from the |authtoken|.
508 508 :type apiuser: AuthUser
509 509 :param repoid: The repository name or repository ID.
510 510 :type repoid: str or int
511 511
512 512 Example output:
513 513
514 514 .. code-block:: bash
515 515
516 516 id : <id_given_in_input>
517 517 result: [
518 518 TODO...
519 519 ]
520 520 error: null
521 521 """
522 522
523 523 repo = get_repo_or_error(repoid)
524 524 if not has_superadmin_permission(apiuser):
525 525 _perms = ('repository.admin', 'repository.write', 'repository.read',)
526 526 has_repo_permissions(apiuser, repoid, repo, _perms)
527 527
528 528 try:
529 529 # check if repo is not empty by any chance, skip quicker if it is.
530 530 vcs_instance = repo.scm_instance()
531 531 refs = vcs_instance.refs()
532 532 return refs
533 533 except Exception:
534 534 log.exception("Exception occurred while trying to get repo refs")
535 535 raise JSONRPCError(
536 536 'failed to get repo: `%s` references' % repo.repo_name
537 537 )
538 538
539 539
540 540 @jsonrpc_method()
541 541 def create_repo(request, apiuser, repo_name, repo_type,
542 542 owner=Optional(OAttr('apiuser')), description=Optional(''),
543 543 private=Optional(False), clone_uri=Optional(None),
544 544 landing_rev=Optional('rev:tip'),
545 545 enable_statistics=Optional(False),
546 546 enable_locking=Optional(False),
547 547 enable_downloads=Optional(False),
548 548 copy_permissions=Optional(False)):
549 549 """
550 550 Creates a repository.
551 551
552 552 * If the repository name contains "/", all the required repository
553 553 groups will be created.
554 554
555 555 For example "foo/bar/baz" will create |repo| groups "foo" and "bar"
556 556 (with "foo" as parent). It will also create the "baz" repository
557 557 with "bar" as |repo| group.
558 558
559 559 This command can only be run using an |authtoken| with at least
560 560 write permissions to the |repo|.
561 561
562 562 :param apiuser: This is filled automatically from the |authtoken|.
563 563 :type apiuser: AuthUser
564 564 :param repo_name: Set the repository name.
565 565 :type repo_name: str
566 566 :param repo_type: Set the repository type; 'hg','git', or 'svn'.
567 567 :type repo_type: str
568 568 :param owner: user_id or username
569 569 :type owner: Optional(str)
570 570 :param description: Set the repository description.
571 571 :type description: Optional(str)
572 572 :param private:
573 573 :type private: bool
574 574 :param clone_uri:
575 575 :type clone_uri: str
576 576 :param landing_rev: <rev_type>:<rev>
577 577 :type landing_rev: str
578 578 :param enable_locking:
579 579 :type enable_locking: bool
580 580 :param enable_downloads:
581 581 :type enable_downloads: bool
582 582 :param enable_statistics:
583 583 :type enable_statistics: bool
584 584 :param copy_permissions: Copy permission from group in which the
585 585 repository is being created.
586 586 :type copy_permissions: bool
587 587
588 588
589 589 Example output:
590 590
591 591 .. code-block:: bash
592 592
593 593 id : <id_given_in_input>
594 594 result: {
595 595 "msg": "Created new repository `<reponame>`",
596 596 "success": true,
597 597 "task": "<celery task id or None if done sync>"
598 598 }
599 599 error: null
600 600
601 601
602 602 Example error output:
603 603
604 604 .. code-block:: bash
605 605
606 606 id : <id_given_in_input>
607 607 result : null
608 608 error : {
609 609 'failed to create repository `<repo_name>`
610 610 }
611 611
612 612 """
613 613 schema = repo_schema.RepoSchema()
614 614 try:
615 615 data = schema.deserialize({
616 616 'repo_name': repo_name
617 617 })
618 618 except colander.Invalid as e:
619 619 raise JSONRPCError("Validation failed: %s" % (e.asdict(),))
620 620 repo_name = data['repo_name']
621 621
622 622 (repo_name_cleaned,
623 623 parent_group_name) = RepoGroupModel()._get_group_name_and_parent(
624 624 repo_name)
625 625
626 626 if not HasPermissionAnyApi(
627 627 'hg.admin', 'hg.create.repository')(user=apiuser):
628 628 # check if we have admin permission for this repo group if given !
629 629
630 630 if parent_group_name:
631 631 repogroupid = parent_group_name
632 632 repo_group = get_repo_group_or_error(parent_group_name)
633 633
634 634 _perms = ('group.admin',)
635 635 if not HasRepoGroupPermissionAnyApi(*_perms)(
636 636 user=apiuser, group_name=repo_group.group_name):
637 637 raise JSONRPCError(
638 638 'repository group `%s` does not exist' % (
639 639 repogroupid,))
640 640 else:
641 641 raise JSONRPCForbidden()
642 642
643 643 if not has_superadmin_permission(apiuser):
644 644 if not isinstance(owner, Optional):
645 645 # forbid setting owner for non-admins
646 646 raise JSONRPCError(
647 647 'Only RhodeCode admin can specify `owner` param')
648 648
649 649 if isinstance(owner, Optional):
650 650 owner = apiuser.user_id
651 651
652 652 owner = get_user_or_error(owner)
653 653
654 654 if RepoModel().get_by_repo_name(repo_name):
655 655 raise JSONRPCError("repo `%s` already exist" % repo_name)
656 656
657 657 defs = SettingsModel().get_default_repo_settings(strip_prefix=True)
658 658 if isinstance(private, Optional):
659 659 private = defs.get('repo_private') or Optional.extract(private)
660 660 if isinstance(repo_type, Optional):
661 661 repo_type = defs.get('repo_type')
662 662 if isinstance(enable_statistics, Optional):
663 663 enable_statistics = defs.get('repo_enable_statistics')
664 664 if isinstance(enable_locking, Optional):
665 665 enable_locking = defs.get('repo_enable_locking')
666 666 if isinstance(enable_downloads, Optional):
667 667 enable_downloads = defs.get('repo_enable_downloads')
668 668
669 669 clone_uri = Optional.extract(clone_uri)
670 670 description = Optional.extract(description)
671 671 landing_rev = Optional.extract(landing_rev)
672 672 copy_permissions = Optional.extract(copy_permissions)
673 673
674 674 try:
675 675 # create structure of groups and return the last group
676 676 repo_group = map_groups(repo_name)
677 677 data = {
678 678 'repo_name': repo_name_cleaned,
679 679 'repo_name_full': repo_name,
680 680 'repo_type': repo_type,
681 681 'repo_description': description,
682 682 'owner': owner,
683 683 'repo_private': private,
684 684 'clone_uri': clone_uri,
685 685 'repo_group': repo_group.group_id if repo_group else None,
686 686 'repo_landing_rev': landing_rev,
687 687 'enable_statistics': enable_statistics,
688 688 'enable_locking': enable_locking,
689 689 'enable_downloads': enable_downloads,
690 690 'repo_copy_permissions': copy_permissions,
691 691 }
692 692
693 693 if repo_type not in BACKENDS.keys():
694 694 raise Exception("Invalid backend type %s" % repo_type)
695 695 task = RepoModel().create(form_data=data, cur_user=owner)
696 696 from celery.result import BaseAsyncResult
697 697 task_id = None
698 698 if isinstance(task, BaseAsyncResult):
699 699 task_id = task.task_id
700 700 # no commit, it's done in RepoModel, or async via celery
701 701 return {
702 702 'msg': "Created new repository `%s`" % (repo_name,),
703 703 'success': True, # cannot return the repo data here since fork
704 704 # cann be done async
705 705 'task': task_id
706 706 }
707 707 except Exception:
708 708 log.exception(
709 709 u"Exception while trying to create the repository %s",
710 710 repo_name)
711 711 raise JSONRPCError(
712 712 'failed to create repository `%s`' % (repo_name,))
713 713
714 714
715 715 @jsonrpc_method()
716 716 def add_field_to_repo(request, apiuser, repoid, key, label=Optional(''),
717 717 description=Optional('')):
718 718 """
719 719 Adds an extra field to a repository.
720 720
721 721 This command can only be run using an |authtoken| with at least
722 722 write permissions to the |repo|.
723 723
724 724 :param apiuser: This is filled automatically from the |authtoken|.
725 725 :type apiuser: AuthUser
726 726 :param repoid: Set the repository name or repository id.
727 727 :type repoid: str or int
728 728 :param key: Create a unique field key for this repository.
729 729 :type key: str
730 730 :param label:
731 731 :type label: Optional(str)
732 732 :param description:
733 733 :type description: Optional(str)
734 734 """
735 735 repo = get_repo_or_error(repoid)
736 736 if not has_superadmin_permission(apiuser):
737 737 _perms = ('repository.admin',)
738 738 has_repo_permissions(apiuser, repoid, repo, _perms)
739 739
740 740 label = Optional.extract(label) or key
741 741 description = Optional.extract(description)
742 742
743 743 field = RepositoryField.get_by_key_name(key, repo)
744 744 if field:
745 745 raise JSONRPCError('Field with key '
746 746 '`%s` exists for repo `%s`' % (key, repoid))
747 747
748 748 try:
749 749 RepoModel().add_repo_field(repo, key, field_label=label,
750 750 field_desc=description)
751 751 Session().commit()
752 752 return {
753 753 'msg': "Added new repository field `%s`" % (key,),
754 754 'success': True,
755 755 }
756 756 except Exception:
757 757 log.exception("Exception occurred while trying to add field to repo")
758 758 raise JSONRPCError(
759 759 'failed to create new field for repository `%s`' % (repoid,))
760 760
761 761
762 762 @jsonrpc_method()
763 763 def remove_field_from_repo(request, apiuser, repoid, key):
764 764 """
765 765 Removes an extra field from a repository.
766 766
767 767 This command can only be run using an |authtoken| with at least
768 768 write permissions to the |repo|.
769 769
770 770 :param apiuser: This is filled automatically from the |authtoken|.
771 771 :type apiuser: AuthUser
772 772 :param repoid: Set the repository name or repository ID.
773 773 :type repoid: str or int
774 774 :param key: Set the unique field key for this repository.
775 775 :type key: str
776 776 """
777 777
778 778 repo = get_repo_or_error(repoid)
779 779 if not has_superadmin_permission(apiuser):
780 780 _perms = ('repository.admin',)
781 781 has_repo_permissions(apiuser, repoid, repo, _perms)
782 782
783 783 field = RepositoryField.get_by_key_name(key, repo)
784 784 if not field:
785 785 raise JSONRPCError('Field with key `%s` does not '
786 786 'exists for repo `%s`' % (key, repoid))
787 787
788 788 try:
789 789 RepoModel().delete_repo_field(repo, field_key=key)
790 790 Session().commit()
791 791 return {
792 792 'msg': "Deleted repository field `%s`" % (key,),
793 793 'success': True,
794 794 }
795 795 except Exception:
796 796 log.exception(
797 797 "Exception occurred while trying to delete field from repo")
798 798 raise JSONRPCError(
799 799 'failed to delete field for repository `%s`' % (repoid,))
800 800
801 801
802 802 @jsonrpc_method()
803 803 def update_repo(request, apiuser, repoid, name=Optional(None),
804 804 owner=Optional(OAttr('apiuser')),
805 805 group=Optional(None),
806 806 fork_of=Optional(None),
807 807 description=Optional(''), private=Optional(False),
808 808 clone_uri=Optional(None), landing_rev=Optional('rev:tip'),
809 809 enable_statistics=Optional(False),
810 810 enable_locking=Optional(False),
811 811 enable_downloads=Optional(False),
812 812 fields=Optional('')):
813 813 """
814 814 Updates a repository with the given information.
815 815
816 816 This command can only be run using an |authtoken| with at least
817 817 write permissions to the |repo|.
818 818
819 819 :param apiuser: This is filled automatically from the |authtoken|.
820 820 :type apiuser: AuthUser
821 821 :param repoid: repository name or repository ID.
822 822 :type repoid: str or int
823 823 :param name: Update the |repo| name.
824 824 :type name: str
825 825 :param owner: Set the |repo| owner.
826 826 :type owner: str
827 827 :param group: Set the |repo| group the |repo| belongs to.
828 828 :type group: str
829 829 :param fork_of: Set the master |repo| name.
830 830 :type fork_of: str
831 831 :param description: Update the |repo| description.
832 832 :type description: str
833 833 :param private: Set the |repo| as private. (True | False)
834 834 :type private: bool
835 835 :param clone_uri: Update the |repo| clone URI.
836 836 :type clone_uri: str
837 837 :param landing_rev: Set the |repo| landing revision. Default is
838 838 ``tip``.
839 839 :type landing_rev: str
840 840 :param enable_statistics: Enable statistics on the |repo|,
841 841 (True | False).
842 842 :type enable_statistics: bool
843 843 :param enable_locking: Enable |repo| locking.
844 844 :type enable_locking: bool
845 845 :param enable_downloads: Enable downloads from the |repo|,
846 846 (True | False).
847 847 :type enable_downloads: bool
848 848 :param fields: Add extra fields to the |repo|. Use the following
849 849 example format: ``field_key=field_val,field_key2=fieldval2``.
850 850 Escape ', ' with \,
851 851 :type fields: str
852 852 """
853 853 repo = get_repo_or_error(repoid)
854 854 include_secrets = False
855 855 if has_superadmin_permission(apiuser):
856 856 include_secrets = True
857 857 else:
858 858 _perms = ('repository.admin',)
859 859 has_repo_permissions(apiuser, repoid, repo, _perms)
860 860
861 861 updates = {
862 862 # update function requires this.
863 863 'repo_name': repo.just_name
864 864 }
865 865 repo_group = group
866 866 if not isinstance(repo_group, Optional):
867 867 repo_group = get_repo_group_or_error(repo_group)
868 868 repo_group = repo_group.group_id
869 869
870 870 repo_fork_of = fork_of
871 871 if not isinstance(repo_fork_of, Optional):
872 872 repo_fork_of = get_repo_or_error(repo_fork_of)
873 873 repo_fork_of = repo_fork_of.repo_id
874 874
875 875 try:
876 876 store_update(updates, name, 'repo_name')
877 877 store_update(updates, repo_group, 'repo_group')
878 878 store_update(updates, repo_fork_of, 'fork_id')
879 879 store_update(updates, owner, 'user')
880 880 store_update(updates, description, 'repo_description')
881 881 store_update(updates, private, 'repo_private')
882 882 store_update(updates, clone_uri, 'clone_uri')
883 883 store_update(updates, landing_rev, 'repo_landing_rev')
884 884 store_update(updates, enable_statistics, 'repo_enable_statistics')
885 885 store_update(updates, enable_locking, 'repo_enable_locking')
886 886 store_update(updates, enable_downloads, 'repo_enable_downloads')
887 887
888 888 # extra fields
889 889 fields = parse_args(Optional.extract(fields), key_prefix='ex_')
890 890 if fields:
891 891 updates.update(fields)
892 892
893 893 RepoModel().update(repo, **updates)
894 894 Session().commit()
895 895 return {
896 896 'msg': 'updated repo ID:%s %s' % (
897 897 repo.repo_id, repo.repo_name),
898 898 'repository': repo.get_api_data(
899 899 include_secrets=include_secrets)
900 900 }
901 901 except Exception:
902 902 log.exception(
903 903 u"Exception while trying to update the repository %s",
904 904 repoid)
905 905 raise JSONRPCError('failed to update repo `%s`' % repoid)
906 906
907 907
908 908 @jsonrpc_method()
909 909 def fork_repo(request, apiuser, repoid, fork_name,
910 910 owner=Optional(OAttr('apiuser')),
911 911 description=Optional(''), copy_permissions=Optional(False),
912 912 private=Optional(False), landing_rev=Optional('rev:tip')):
913 913 """
914 914 Creates a fork of the specified |repo|.
915 915
916 916 * If using |RCE| with Celery this will immediately return a success
917 917 message, even though the fork will be created asynchronously.
918 918
919 919 This command can only be run using an |authtoken| with fork
920 920 permissions on the |repo|.
921 921
922 922 :param apiuser: This is filled automatically from the |authtoken|.
923 923 :type apiuser: AuthUser
924 924 :param repoid: Set repository name or repository ID.
925 925 :type repoid: str or int
926 926 :param fork_name: Set the fork name.
927 927 :type fork_name: str
928 928 :param owner: Set the fork owner.
929 929 :type owner: str
930 930 :param description: Set the fork descripton.
931 931 :type description: str
932 932 :param copy_permissions: Copy permissions from parent |repo|. The
933 933 default is False.
934 934 :type copy_permissions: bool
935 935 :param private: Make the fork private. The default is False.
936 936 :type private: bool
937 937 :param landing_rev: Set the landing revision. The default is tip.
938 938
939 939 Example output:
940 940
941 941 .. code-block:: bash
942 942
943 943 id : <id_for_response>
944 944 api_key : "<api_key>"
945 945 args: {
946 946 "repoid" : "<reponame or repo_id>",
947 947 "fork_name": "<forkname>",
948 948 "owner": "<username or user_id = Optional(=apiuser)>",
949 949 "description": "<description>",
950 950 "copy_permissions": "<bool>",
951 951 "private": "<bool>",
952 952 "landing_rev": "<landing_rev>"
953 953 }
954 954
955 955 Example error output:
956 956
957 957 .. code-block:: bash
958 958
959 959 id : <id_given_in_input>
960 960 result: {
961 961 "msg": "Created fork of `<reponame>` as `<forkname>`",
962 962 "success": true,
963 963 "task": "<celery task id or None if done sync>"
964 964 }
965 965 error: null
966 966
967 967 """
968 968 if not has_superadmin_permission(apiuser):
969 969 if not HasPermissionAnyApi('hg.fork.repository')(user=apiuser):
970 970 raise JSONRPCForbidden()
971 971
972 972 repo = get_repo_or_error(repoid)
973 973 repo_name = repo.repo_name
974 974
975 975 (fork_name_cleaned,
976 976 parent_group_name) = RepoGroupModel()._get_group_name_and_parent(
977 977 fork_name)
978 978
979 979 if not has_superadmin_permission(apiuser):
980 980 # check if we have at least read permission for
981 981 # this repo that we fork !
982 982 _perms = (
983 983 'repository.admin', 'repository.write', 'repository.read')
984 984 has_repo_permissions(apiuser, repoid, repo, _perms)
985 985
986 986 if not isinstance(owner, Optional):
987 987 # forbid setting owner for non super admins
988 988 raise JSONRPCError(
989 989 'Only RhodeCode admin can specify `owner` param'
990 990 )
991 991 # check if we have a create.repo permission if not maybe the parent
992 992 # group permission
993 993 if not HasPermissionAnyApi('hg.create.repository')(user=apiuser):
994 994 if parent_group_name:
995 995 repogroupid = parent_group_name
996 996 repo_group = get_repo_group_or_error(parent_group_name)
997 997
998 998 _perms = ('group.admin',)
999 999 if not HasRepoGroupPermissionAnyApi(*_perms)(
1000 1000 user=apiuser, group_name=repo_group.group_name):
1001 1001 raise JSONRPCError(
1002 1002 'repository group `%s` does not exist' % (
1003 1003 repogroupid,))
1004 1004 else:
1005 1005 raise JSONRPCForbidden()
1006 1006
1007 1007 _repo = RepoModel().get_by_repo_name(fork_name)
1008 1008 if _repo:
1009 1009 type_ = 'fork' if _repo.fork else 'repo'
1010 1010 raise JSONRPCError("%s `%s` already exist" % (type_, fork_name))
1011 1011
1012 1012 if isinstance(owner, Optional):
1013 1013 owner = apiuser.user_id
1014 1014
1015 1015 owner = get_user_or_error(owner)
1016 1016
1017 1017 try:
1018 1018 # create structure of groups and return the last group
1019 1019 repo_group = map_groups(fork_name)
1020 1020 form_data = {
1021 1021 'repo_name': fork_name_cleaned,
1022 1022 'repo_name_full': fork_name,
1023 1023 'repo_group': repo_group.group_id if repo_group else None,
1024 1024 'repo_type': repo.repo_type,
1025 1025 'description': Optional.extract(description),
1026 1026 'private': Optional.extract(private),
1027 1027 'copy_permissions': Optional.extract(copy_permissions),
1028 1028 'landing_rev': Optional.extract(landing_rev),
1029 1029 'fork_parent_id': repo.repo_id,
1030 1030 }
1031 1031
1032 1032 task = RepoModel().create_fork(form_data, cur_user=owner)
1033 1033 # no commit, it's done in RepoModel, or async via celery
1034 1034 from celery.result import BaseAsyncResult
1035 1035 task_id = None
1036 1036 if isinstance(task, BaseAsyncResult):
1037 1037 task_id = task.task_id
1038 1038 return {
1039 1039 'msg': 'Created fork of `%s` as `%s`' % (
1040 1040 repo.repo_name, fork_name),
1041 1041 'success': True, # cannot return the repo data here since fork
1042 1042 # can be done async
1043 1043 'task': task_id
1044 1044 }
1045 1045 except Exception:
1046 1046 log.exception("Exception occurred while trying to fork a repo")
1047 1047 raise JSONRPCError(
1048 1048 'failed to fork repository `%s` as `%s`' % (
1049 1049 repo_name, fork_name))
1050 1050
1051 1051
1052 1052 @jsonrpc_method()
1053 1053 def delete_repo(request, apiuser, repoid, forks=Optional('')):
1054 1054 """
1055 1055 Deletes a repository.
1056 1056
1057 1057 * When the `forks` parameter is set it's possible to detach or delete
1058 1058 forks of deleted repository.
1059 1059
1060 1060 This command can only be run using an |authtoken| with admin
1061 1061 permissions on the |repo|.
1062 1062
1063 1063 :param apiuser: This is filled automatically from the |authtoken|.
1064 1064 :type apiuser: AuthUser
1065 1065 :param repoid: Set the repository name or repository ID.
1066 1066 :type repoid: str or int
1067 1067 :param forks: Set to `detach` or `delete` forks from the |repo|.
1068 1068 :type forks: Optional(str)
1069 1069
1070 1070 Example error output:
1071 1071
1072 1072 .. code-block:: bash
1073 1073
1074 1074 id : <id_given_in_input>
1075 1075 result: {
1076 1076 "msg": "Deleted repository `<reponame>`",
1077 1077 "success": true
1078 1078 }
1079 1079 error: null
1080 1080 """
1081 1081
1082 1082 repo = get_repo_or_error(repoid)
1083 1083 if not has_superadmin_permission(apiuser):
1084 1084 _perms = ('repository.admin',)
1085 1085 has_repo_permissions(apiuser, repoid, repo, _perms)
1086 1086
1087 1087 try:
1088 1088 handle_forks = Optional.extract(forks)
1089 1089 _forks_msg = ''
1090 1090 _forks = [f for f in repo.forks]
1091 1091 if handle_forks == 'detach':
1092 1092 _forks_msg = ' ' + 'Detached %s forks' % len(_forks)
1093 1093 elif handle_forks == 'delete':
1094 1094 _forks_msg = ' ' + 'Deleted %s forks' % len(_forks)
1095 1095 elif _forks:
1096 1096 raise JSONRPCError(
1097 1097 'Cannot delete `%s` it still contains attached forks' %
1098 1098 (repo.repo_name,)
1099 1099 )
1100 1100
1101 1101 RepoModel().delete(repo, forks=forks)
1102 1102 Session().commit()
1103 1103 return {
1104 1104 'msg': 'Deleted repository `%s`%s' % (
1105 1105 repo.repo_name, _forks_msg),
1106 1106 'success': True
1107 1107 }
1108 1108 except Exception:
1109 1109 log.exception("Exception occurred while trying to delete repo")
1110 1110 raise JSONRPCError(
1111 1111 'failed to delete repository `%s`' % (repo.repo_name,)
1112 1112 )
1113 1113
1114 1114
1115 1115 #TODO: marcink, change name ?
1116 1116 @jsonrpc_method()
1117 1117 def invalidate_cache(request, apiuser, repoid, delete_keys=Optional(False)):
1118 1118 """
1119 1119 Invalidates the cache for the specified repository.
1120 1120
1121 1121 This command can only be run using an |authtoken| with admin rights to
1122 1122 the specified repository.
1123 1123
1124 1124 This command takes the following options:
1125 1125
1126 1126 :param apiuser: This is filled automatically from |authtoken|.
1127 1127 :type apiuser: AuthUser
1128 1128 :param repoid: Sets the repository name or repository ID.
1129 1129 :type repoid: str or int
1130 1130 :param delete_keys: This deletes the invalidated keys instead of
1131 1131 just flagging them.
1132 1132 :type delete_keys: Optional(``True`` | ``False``)
1133 1133
1134 1134 Example output:
1135 1135
1136 1136 .. code-block:: bash
1137 1137
1138 1138 id : <id_given_in_input>
1139 1139 result : {
1140 1140 'msg': Cache for repository `<repository name>` was invalidated,
1141 1141 'repository': <repository name>
1142 1142 }
1143 1143 error : null
1144 1144
1145 1145 Example error output:
1146 1146
1147 1147 .. code-block:: bash
1148 1148
1149 1149 id : <id_given_in_input>
1150 1150 result : null
1151 1151 error : {
1152 1152 'Error occurred during cache invalidation action'
1153 1153 }
1154 1154
1155 1155 """
1156 1156
1157 1157 repo = get_repo_or_error(repoid)
1158 1158 if not has_superadmin_permission(apiuser):
1159 1159 _perms = ('repository.admin', 'repository.write',)
1160 1160 has_repo_permissions(apiuser, repoid, repo, _perms)
1161 1161
1162 1162 delete = Optional.extract(delete_keys)
1163 1163 try:
1164 1164 ScmModel().mark_for_invalidation(repo.repo_name, delete=delete)
1165 1165 return {
1166 1166 'msg': 'Cache for repository `%s` was invalidated' % (repoid,),
1167 1167 'repository': repo.repo_name
1168 1168 }
1169 1169 except Exception:
1170 1170 log.exception(
1171 1171 "Exception occurred while trying to invalidate repo cache")
1172 1172 raise JSONRPCError(
1173 1173 'Error occurred during cache invalidation action'
1174 1174 )
1175 1175
1176 1176
1177 1177 #TODO: marcink, change name ?
1178 1178 @jsonrpc_method()
1179 1179 def lock(request, apiuser, repoid, locked=Optional(None),
1180 1180 userid=Optional(OAttr('apiuser'))):
1181 1181 """
1182 1182 Sets the lock state of the specified |repo| by the given user.
1183 1183 From more information, see :ref:`repo-locking`.
1184 1184
1185 1185 * If the ``userid`` option is not set, the repository is locked to the
1186 1186 user who called the method.
1187 1187 * If the ``locked`` parameter is not set, the current lock state of the
1188 1188 repository is displayed.
1189 1189
1190 1190 This command can only be run using an |authtoken| with admin rights to
1191 1191 the specified repository.
1192 1192
1193 1193 This command takes the following options:
1194 1194
1195 1195 :param apiuser: This is filled automatically from the |authtoken|.
1196 1196 :type apiuser: AuthUser
1197 1197 :param repoid: Sets the repository name or repository ID.
1198 1198 :type repoid: str or int
1199 1199 :param locked: Sets the lock state.
1200 1200 :type locked: Optional(``True`` | ``False``)
1201 1201 :param userid: Set the repository lock to this user.
1202 1202 :type userid: Optional(str or int)
1203 1203
1204 1204 Example error output:
1205 1205
1206 1206 .. code-block:: bash
1207 1207
1208 1208 id : <id_given_in_input>
1209 1209 result : {
1210 1210 'repo': '<reponame>',
1211 1211 'locked': <bool: lock state>,
1212 1212 'locked_since': <int: lock timestamp>,
1213 1213 'locked_by': <username of person who made the lock>,
1214 1214 'lock_reason': <str: reason for locking>,
1215 1215 'lock_state_changed': <bool: True if lock state has been changed in this request>,
1216 1216 'msg': 'Repo `<reponame>` locked by `<username>` on <timestamp>.'
1217 1217 or
1218 1218 'msg': 'Repo `<repository name>` not locked.'
1219 1219 or
1220 1220 'msg': 'User `<user name>` set lock state for repo `<repository name>` to `<new lock state>`'
1221 1221 }
1222 1222 error : null
1223 1223
1224 1224 Example error output:
1225 1225
1226 1226 .. code-block:: bash
1227 1227
1228 1228 id : <id_given_in_input>
1229 1229 result : null
1230 1230 error : {
1231 1231 'Error occurred locking repository `<reponame>`
1232 1232 }
1233 1233 """
1234 1234
1235 1235 repo = get_repo_or_error(repoid)
1236 1236 if not has_superadmin_permission(apiuser):
1237 1237 # check if we have at least write permission for this repo !
1238 1238 _perms = ('repository.admin', 'repository.write',)
1239 1239 has_repo_permissions(apiuser, repoid, repo, _perms)
1240 1240
1241 1241 # make sure normal user does not pass someone else userid,
1242 1242 # he is not allowed to do that
1243 1243 if not isinstance(userid, Optional) and userid != apiuser.user_id:
1244 1244 raise JSONRPCError('userid is not the same as your user')
1245 1245
1246 1246 if isinstance(userid, Optional):
1247 1247 userid = apiuser.user_id
1248 1248
1249 1249 user = get_user_or_error(userid)
1250 1250
1251 1251 if isinstance(locked, Optional):
1252 1252 lockobj = repo.locked
1253 1253
1254 1254 if lockobj[0] is None:
1255 1255 _d = {
1256 1256 'repo': repo.repo_name,
1257 1257 'locked': False,
1258 1258 'locked_since': None,
1259 1259 'locked_by': None,
1260 1260 'lock_reason': None,
1261 1261 'lock_state_changed': False,
1262 1262 'msg': 'Repo `%s` not locked.' % repo.repo_name
1263 1263 }
1264 1264 return _d
1265 1265 else:
1266 1266 _user_id, _time, _reason = lockobj
1267 1267 lock_user = get_user_or_error(userid)
1268 1268 _d = {
1269 1269 'repo': repo.repo_name,
1270 1270 'locked': True,
1271 1271 'locked_since': _time,
1272 1272 'locked_by': lock_user.username,
1273 1273 'lock_reason': _reason,
1274 1274 'lock_state_changed': False,
1275 1275 'msg': ('Repo `%s` locked by `%s` on `%s`.'
1276 1276 % (repo.repo_name, lock_user.username,
1277 1277 json.dumps(time_to_datetime(_time))))
1278 1278 }
1279 1279 return _d
1280 1280
1281 1281 # force locked state through a flag
1282 1282 else:
1283 1283 locked = str2bool(locked)
1284 1284 lock_reason = Repository.LOCK_API
1285 1285 try:
1286 1286 if locked:
1287 1287 lock_time = time.time()
1288 1288 Repository.lock(repo, user.user_id, lock_time, lock_reason)
1289 1289 else:
1290 1290 lock_time = None
1291 1291 Repository.unlock(repo)
1292 1292 _d = {
1293 1293 'repo': repo.repo_name,
1294 1294 'locked': locked,
1295 1295 'locked_since': lock_time,
1296 1296 'locked_by': user.username,
1297 1297 'lock_reason': lock_reason,
1298 1298 'lock_state_changed': True,
1299 1299 'msg': ('User `%s` set lock state for repo `%s` to `%s`'
1300 1300 % (user.username, repo.repo_name, locked))
1301 1301 }
1302 1302 return _d
1303 1303 except Exception:
1304 1304 log.exception(
1305 1305 "Exception occurred while trying to lock repository")
1306 1306 raise JSONRPCError(
1307 1307 'Error occurred locking repository `%s`' % repo.repo_name
1308 1308 )
1309 1309
1310 1310
1311 1311 @jsonrpc_method()
1312 1312 def comment_commit(
1313 1313 request, apiuser, repoid, commit_id, message,
1314 1314 userid=Optional(OAttr('apiuser')), status=Optional(None)):
1315 1315 """
1316 1316 Set a commit comment, and optionally change the status of the commit.
1317 1317
1318 1318 :param apiuser: This is filled automatically from the |authtoken|.
1319 1319 :type apiuser: AuthUser
1320 1320 :param repoid: Set the repository name or repository ID.
1321 1321 :type repoid: str or int
1322 1322 :param commit_id: Specify the commit_id for which to set a comment.
1323 1323 :type commit_id: str
1324 1324 :param message: The comment text.
1325 1325 :type message: str
1326 1326 :param userid: Set the user name of the comment creator.
1327 1327 :type userid: Optional(str or int)
1328 1328 :param status: status, one of 'not_reviewed', 'approved', 'rejected',
1329 1329 'under_review'
1330 1330 :type status: str
1331 1331
1332 1332 Example error output:
1333 1333
1334 1334 .. code-block:: json
1335 1335
1336 1336 {
1337 1337 "id" : <id_given_in_input>,
1338 1338 "result" : {
1339 1339 "msg": "Commented on commit `<commit_id>` for repository `<repoid>`",
1340 1340 "status_change": null or <status>,
1341 1341 "success": true
1342 1342 },
1343 1343 "error" : null
1344 1344 }
1345 1345
1346 1346 """
1347 1347 repo = get_repo_or_error(repoid)
1348 1348 if not has_superadmin_permission(apiuser):
1349 1349 _perms = ('repository.read', 'repository.write', 'repository.admin')
1350 1350 has_repo_permissions(apiuser, repoid, repo, _perms)
1351 1351
1352 1352 if isinstance(userid, Optional):
1353 1353 userid = apiuser.user_id
1354 1354
1355 1355 user = get_user_or_error(userid)
1356 1356 status = Optional.extract(status)
1357 1357
1358 1358 allowed_statuses = [x[0] for x in ChangesetStatus.STATUSES]
1359 1359 if status and status not in allowed_statuses:
1360 1360 raise JSONRPCError('Bad status, must be on '
1361 1361 'of %s got %s' % (allowed_statuses, status,))
1362 1362
1363 1363 try:
1364 1364 rc_config = SettingsModel().get_all_settings()
1365 1365 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
1366
1366 status_change_label = ChangesetStatus.get_status_lbl(status)
1367 1367 comm = ChangesetCommentsModel().create(
1368 message, repo, user, revision=commit_id, status_change=status,
1368 message, repo, user, revision=commit_id,
1369 status_change=status_change_label,
1370 status_change_type=status,
1369 1371 renderer=renderer)
1370 1372 if status:
1371 1373 # also do a status change
1372 1374 try:
1373 1375 ChangesetStatusModel().set_status(
1374 1376 repo, status, user, comm, revision=commit_id,
1375 1377 dont_allow_on_closed_pull_request=True
1376 1378 )
1377 1379 except StatusChangeOnClosedPullRequestError:
1378 1380 log.exception(
1379 1381 "Exception occurred while trying to change repo commit status")
1380 1382 msg = ('Changing status on a changeset associated with '
1381 1383 'a closed pull request is not allowed')
1382 1384 raise JSONRPCError(msg)
1383 1385
1384 1386 Session().commit()
1385 1387 return {
1386 1388 'msg': (
1387 1389 'Commented on commit `%s` for repository `%s`' % (
1388 1390 comm.revision, repo.repo_name)),
1389 1391 'status_change': status,
1390 1392 'success': True,
1391 1393 }
1392 1394 except JSONRPCError:
1393 1395 # catch any inside errors, and re-raise them to prevent from
1394 1396 # below global catch to silence them
1395 1397 raise
1396 1398 except Exception:
1397 1399 log.exception("Exception occurred while trying to comment on commit")
1398 1400 raise JSONRPCError(
1399 1401 'failed to set comment on repository `%s`' % (repo.repo_name,)
1400 1402 )
1401 1403
1402 1404
1403 1405 @jsonrpc_method()
1404 1406 def grant_user_permission(request, apiuser, repoid, userid, perm):
1405 1407 """
1406 1408 Grant permissions for the specified user on the given repository,
1407 1409 or update existing permissions if found.
1408 1410
1409 1411 This command can only be run using an |authtoken| with admin
1410 1412 permissions on the |repo|.
1411 1413
1412 1414 :param apiuser: This is filled automatically from the |authtoken|.
1413 1415 :type apiuser: AuthUser
1414 1416 :param repoid: Set the repository name or repository ID.
1415 1417 :type repoid: str or int
1416 1418 :param userid: Set the user name.
1417 1419 :type userid: str
1418 1420 :param perm: Set the user permissions, using the following format
1419 1421 ``(repository.(none|read|write|admin))``
1420 1422 :type perm: str
1421 1423
1422 1424 Example output:
1423 1425
1424 1426 .. code-block:: bash
1425 1427
1426 1428 id : <id_given_in_input>
1427 1429 result: {
1428 1430 "msg" : "Granted perm: `<perm>` for user: `<username>` in repo: `<reponame>`",
1429 1431 "success": true
1430 1432 }
1431 1433 error: null
1432 1434 """
1433 1435
1434 1436 repo = get_repo_or_error(repoid)
1435 1437 user = get_user_or_error(userid)
1436 1438 perm = get_perm_or_error(perm)
1437 1439 if not has_superadmin_permission(apiuser):
1438 1440 _perms = ('repository.admin',)
1439 1441 has_repo_permissions(apiuser, repoid, repo, _perms)
1440 1442
1441 1443 try:
1442 1444
1443 1445 RepoModel().grant_user_permission(repo=repo, user=user, perm=perm)
1444 1446
1445 1447 Session().commit()
1446 1448 return {
1447 1449 'msg': 'Granted perm: `%s` for user: `%s` in repo: `%s`' % (
1448 1450 perm.permission_name, user.username, repo.repo_name
1449 1451 ),
1450 1452 'success': True
1451 1453 }
1452 1454 except Exception:
1453 1455 log.exception(
1454 1456 "Exception occurred while trying edit permissions for repo")
1455 1457 raise JSONRPCError(
1456 1458 'failed to edit permission for user: `%s` in repo: `%s`' % (
1457 1459 userid, repoid
1458 1460 )
1459 1461 )
1460 1462
1461 1463
1462 1464 @jsonrpc_method()
1463 1465 def revoke_user_permission(request, apiuser, repoid, userid):
1464 1466 """
1465 1467 Revoke permission for a user on the specified repository.
1466 1468
1467 1469 This command can only be run using an |authtoken| with admin
1468 1470 permissions on the |repo|.
1469 1471
1470 1472 :param apiuser: This is filled automatically from the |authtoken|.
1471 1473 :type apiuser: AuthUser
1472 1474 :param repoid: Set the repository name or repository ID.
1473 1475 :type repoid: str or int
1474 1476 :param userid: Set the user name of revoked user.
1475 1477 :type userid: str or int
1476 1478
1477 1479 Example error output:
1478 1480
1479 1481 .. code-block:: bash
1480 1482
1481 1483 id : <id_given_in_input>
1482 1484 result: {
1483 1485 "msg" : "Revoked perm for user: `<username>` in repo: `<reponame>`",
1484 1486 "success": true
1485 1487 }
1486 1488 error: null
1487 1489 """
1488 1490
1489 1491 repo = get_repo_or_error(repoid)
1490 1492 user = get_user_or_error(userid)
1491 1493 if not has_superadmin_permission(apiuser):
1492 1494 _perms = ('repository.admin',)
1493 1495 has_repo_permissions(apiuser, repoid, repo, _perms)
1494 1496
1495 1497 try:
1496 1498 RepoModel().revoke_user_permission(repo=repo, user=user)
1497 1499 Session().commit()
1498 1500 return {
1499 1501 'msg': 'Revoked perm for user: `%s` in repo: `%s`' % (
1500 1502 user.username, repo.repo_name
1501 1503 ),
1502 1504 'success': True
1503 1505 }
1504 1506 except Exception:
1505 1507 log.exception(
1506 1508 "Exception occurred while trying revoke permissions to repo")
1507 1509 raise JSONRPCError(
1508 1510 'failed to edit permission for user: `%s` in repo: `%s`' % (
1509 1511 userid, repoid
1510 1512 )
1511 1513 )
1512 1514
1513 1515
1514 1516 @jsonrpc_method()
1515 1517 def grant_user_group_permission(request, apiuser, repoid, usergroupid, perm):
1516 1518 """
1517 1519 Grant permission for a user group on the specified repository,
1518 1520 or update existing permissions.
1519 1521
1520 1522 This command can only be run using an |authtoken| with admin
1521 1523 permissions on the |repo|.
1522 1524
1523 1525 :param apiuser: This is filled automatically from the |authtoken|.
1524 1526 :type apiuser: AuthUser
1525 1527 :param repoid: Set the repository name or repository ID.
1526 1528 :type repoid: str or int
1527 1529 :param usergroupid: Specify the ID of the user group.
1528 1530 :type usergroupid: str or int
1529 1531 :param perm: Set the user group permissions using the following
1530 1532 format: (repository.(none|read|write|admin))
1531 1533 :type perm: str
1532 1534
1533 1535 Example output:
1534 1536
1535 1537 .. code-block:: bash
1536 1538
1537 1539 id : <id_given_in_input>
1538 1540 result : {
1539 1541 "msg" : "Granted perm: `<perm>` for group: `<usersgroupname>` in repo: `<reponame>`",
1540 1542 "success": true
1541 1543
1542 1544 }
1543 1545 error : null
1544 1546
1545 1547 Example error output:
1546 1548
1547 1549 .. code-block:: bash
1548 1550
1549 1551 id : <id_given_in_input>
1550 1552 result : null
1551 1553 error : {
1552 1554 "failed to edit permission for user group: `<usergroup>` in repo `<repo>`'
1553 1555 }
1554 1556
1555 1557 """
1556 1558
1557 1559 repo = get_repo_or_error(repoid)
1558 1560 perm = get_perm_or_error(perm)
1559 1561 if not has_superadmin_permission(apiuser):
1560 1562 _perms = ('repository.admin',)
1561 1563 has_repo_permissions(apiuser, repoid, repo, _perms)
1562 1564
1563 1565 user_group = get_user_group_or_error(usergroupid)
1564 1566 if not has_superadmin_permission(apiuser):
1565 1567 # check if we have at least read permission for this user group !
1566 1568 _perms = ('usergroup.read', 'usergroup.write', 'usergroup.admin',)
1567 1569 if not HasUserGroupPermissionAnyApi(*_perms)(
1568 1570 user=apiuser, user_group_name=user_group.users_group_name):
1569 1571 raise JSONRPCError(
1570 1572 'user group `%s` does not exist' % (usergroupid,))
1571 1573
1572 1574 try:
1573 1575 RepoModel().grant_user_group_permission(
1574 1576 repo=repo, group_name=user_group, perm=perm)
1575 1577
1576 1578 Session().commit()
1577 1579 return {
1578 1580 'msg': 'Granted perm: `%s` for user group: `%s` in '
1579 1581 'repo: `%s`' % (
1580 1582 perm.permission_name, user_group.users_group_name,
1581 1583 repo.repo_name
1582 1584 ),
1583 1585 'success': True
1584 1586 }
1585 1587 except Exception:
1586 1588 log.exception(
1587 1589 "Exception occurred while trying change permission on repo")
1588 1590 raise JSONRPCError(
1589 1591 'failed to edit permission for user group: `%s` in '
1590 1592 'repo: `%s`' % (
1591 1593 usergroupid, repo.repo_name
1592 1594 )
1593 1595 )
1594 1596
1595 1597
1596 1598 @jsonrpc_method()
1597 1599 def revoke_user_group_permission(request, apiuser, repoid, usergroupid):
1598 1600 """
1599 1601 Revoke the permissions of a user group on a given repository.
1600 1602
1601 1603 This command can only be run using an |authtoken| with admin
1602 1604 permissions on the |repo|.
1603 1605
1604 1606 :param apiuser: This is filled automatically from the |authtoken|.
1605 1607 :type apiuser: AuthUser
1606 1608 :param repoid: Set the repository name or repository ID.
1607 1609 :type repoid: str or int
1608 1610 :param usergroupid: Specify the user group ID.
1609 1611 :type usergroupid: str or int
1610 1612
1611 1613 Example output:
1612 1614
1613 1615 .. code-block:: bash
1614 1616
1615 1617 id : <id_given_in_input>
1616 1618 result: {
1617 1619 "msg" : "Revoked perm for group: `<usersgroupname>` in repo: `<reponame>`",
1618 1620 "success": true
1619 1621 }
1620 1622 error: null
1621 1623 """
1622 1624
1623 1625 repo = get_repo_or_error(repoid)
1624 1626 if not has_superadmin_permission(apiuser):
1625 1627 _perms = ('repository.admin',)
1626 1628 has_repo_permissions(apiuser, repoid, repo, _perms)
1627 1629
1628 1630 user_group = get_user_group_or_error(usergroupid)
1629 1631 if not has_superadmin_permission(apiuser):
1630 1632 # check if we have at least read permission for this user group !
1631 1633 _perms = ('usergroup.read', 'usergroup.write', 'usergroup.admin',)
1632 1634 if not HasUserGroupPermissionAnyApi(*_perms)(
1633 1635 user=apiuser, user_group_name=user_group.users_group_name):
1634 1636 raise JSONRPCError(
1635 1637 'user group `%s` does not exist' % (usergroupid,))
1636 1638
1637 1639 try:
1638 1640 RepoModel().revoke_user_group_permission(
1639 1641 repo=repo, group_name=user_group)
1640 1642
1641 1643 Session().commit()
1642 1644 return {
1643 1645 'msg': 'Revoked perm for user group: `%s` in repo: `%s`' % (
1644 1646 user_group.users_group_name, repo.repo_name
1645 1647 ),
1646 1648 'success': True
1647 1649 }
1648 1650 except Exception:
1649 1651 log.exception("Exception occurred while trying revoke "
1650 1652 "user group permission on repo")
1651 1653 raise JSONRPCError(
1652 1654 'failed to edit permission for user group: `%s` in '
1653 1655 'repo: `%s`' % (
1654 1656 user_group.users_group_name, repo.repo_name
1655 1657 )
1656 1658 )
1657 1659
1658 1660
1659 1661 @jsonrpc_method()
1660 1662 def pull(request, apiuser, repoid):
1661 1663 """
1662 1664 Triggers a pull on the given repository from a remote location. You
1663 1665 can use this to keep remote repositories up-to-date.
1664 1666
1665 1667 This command can only be run using an |authtoken| with admin
1666 1668 rights to the specified repository. For more information,
1667 1669 see :ref:`config-token-ref`.
1668 1670
1669 1671 This command takes the following options:
1670 1672
1671 1673 :param apiuser: This is filled automatically from the |authtoken|.
1672 1674 :type apiuser: AuthUser
1673 1675 :param repoid: The repository name or repository ID.
1674 1676 :type repoid: str or int
1675 1677
1676 1678 Example output:
1677 1679
1678 1680 .. code-block:: bash
1679 1681
1680 1682 id : <id_given_in_input>
1681 1683 result : {
1682 1684 "msg": "Pulled from `<repository name>`"
1683 1685 "repository": "<repository name>"
1684 1686 }
1685 1687 error : null
1686 1688
1687 1689 Example error output:
1688 1690
1689 1691 .. code-block:: bash
1690 1692
1691 1693 id : <id_given_in_input>
1692 1694 result : null
1693 1695 error : {
1694 1696 "Unable to pull changes from `<reponame>`"
1695 1697 }
1696 1698
1697 1699 """
1698 1700
1699 1701 repo = get_repo_or_error(repoid)
1700 1702 if not has_superadmin_permission(apiuser):
1701 1703 _perms = ('repository.admin',)
1702 1704 has_repo_permissions(apiuser, repoid, repo, _perms)
1703 1705
1704 1706 try:
1705 1707 ScmModel().pull_changes(repo.repo_name, apiuser.username)
1706 1708 return {
1707 1709 'msg': 'Pulled from `%s`' % repo.repo_name,
1708 1710 'repository': repo.repo_name
1709 1711 }
1710 1712 except Exception:
1711 1713 log.exception("Exception occurred while trying to "
1712 1714 "pull changes from remote location")
1713 1715 raise JSONRPCError(
1714 1716 'Unable to pull changes from `%s`' % repo.repo_name
1715 1717 )
1716 1718
1717 1719
1718 1720 @jsonrpc_method()
1719 1721 def strip(request, apiuser, repoid, revision, branch):
1720 1722 """
1721 1723 Strips the given revision from the specified repository.
1722 1724
1723 1725 * This will remove the revision and all of its decendants.
1724 1726
1725 1727 This command can only be run using an |authtoken| with admin rights to
1726 1728 the specified repository.
1727 1729
1728 1730 This command takes the following options:
1729 1731
1730 1732 :param apiuser: This is filled automatically from the |authtoken|.
1731 1733 :type apiuser: AuthUser
1732 1734 :param repoid: The repository name or repository ID.
1733 1735 :type repoid: str or int
1734 1736 :param revision: The revision you wish to strip.
1735 1737 :type revision: str
1736 1738 :param branch: The branch from which to strip the revision.
1737 1739 :type branch: str
1738 1740
1739 1741 Example output:
1740 1742
1741 1743 .. code-block:: bash
1742 1744
1743 1745 id : <id_given_in_input>
1744 1746 result : {
1745 1747 "msg": "'Stripped commit <commit_hash> from repo `<repository name>`'"
1746 1748 "repository": "<repository name>"
1747 1749 }
1748 1750 error : null
1749 1751
1750 1752 Example error output:
1751 1753
1752 1754 .. code-block:: bash
1753 1755
1754 1756 id : <id_given_in_input>
1755 1757 result : null
1756 1758 error : {
1757 1759 "Unable to strip commit <commit_hash> from repo `<repository name>`"
1758 1760 }
1759 1761
1760 1762 """
1761 1763
1762 1764 repo = get_repo_or_error(repoid)
1763 1765 if not has_superadmin_permission(apiuser):
1764 1766 _perms = ('repository.admin',)
1765 1767 has_repo_permissions(apiuser, repoid, repo, _perms)
1766 1768
1767 1769 try:
1768 1770 ScmModel().strip(repo, revision, branch)
1769 1771 return {
1770 1772 'msg': 'Stripped commit %s from repo `%s`' % (
1771 1773 revision, repo.repo_name),
1772 1774 'repository': repo.repo_name
1773 1775 }
1774 1776 except Exception:
1775 1777 log.exception("Exception while trying to strip")
1776 1778 raise JSONRPCError(
1777 1779 'Unable to strip commit %s from repo `%s`' % (
1778 1780 revision, repo.repo_name)
1779 1781 )
1780 1782
1781 1783
1782 1784 @jsonrpc_method()
1783 1785 def get_repo_settings(request, apiuser, repoid, key=Optional(None)):
1784 1786 """
1785 1787 Returns all settings for a repository. If key is given it only returns the
1786 1788 setting identified by the key or null.
1787 1789
1788 1790 :param apiuser: This is filled automatically from the |authtoken|.
1789 1791 :type apiuser: AuthUser
1790 1792 :param repoid: The repository name or repository id.
1791 1793 :type repoid: str or int
1792 1794 :param key: Key of the setting to return.
1793 1795 :type: key: Optional(str)
1794 1796
1795 1797 Example output:
1796 1798
1797 1799 .. code-block:: bash
1798 1800
1799 1801 {
1800 1802 "error": null,
1801 1803 "id": 237,
1802 1804 "result": {
1803 1805 "extensions_largefiles": true,
1804 1806 "hooks_changegroup_push_logger": true,
1805 1807 "hooks_changegroup_repo_size": false,
1806 1808 "hooks_outgoing_pull_logger": true,
1807 1809 "phases_publish": "True",
1808 1810 "rhodecode_hg_use_rebase_for_merging": true,
1809 1811 "rhodecode_pr_merge_enabled": true,
1810 1812 "rhodecode_use_outdated_comments": true
1811 1813 }
1812 1814 }
1813 1815 """
1814 1816
1815 1817 # Restrict access to this api method to admins only.
1816 1818 if not has_superadmin_permission(apiuser):
1817 1819 raise JSONRPCForbidden()
1818 1820
1819 1821 try:
1820 1822 repo = get_repo_or_error(repoid)
1821 1823 settings_model = VcsSettingsModel(repo=repo)
1822 1824 settings = settings_model.get_global_settings()
1823 1825 settings.update(settings_model.get_repo_settings())
1824 1826
1825 1827 # If only a single setting is requested fetch it from all settings.
1826 1828 key = Optional.extract(key)
1827 1829 if key is not None:
1828 1830 settings = settings.get(key, None)
1829 1831 except Exception:
1830 1832 msg = 'Failed to fetch settings for repository `{}`'.format(repoid)
1831 1833 log.exception(msg)
1832 1834 raise JSONRPCError(msg)
1833 1835
1834 1836 return settings
1835 1837
1836 1838
1837 1839 @jsonrpc_method()
1838 1840 def set_repo_settings(request, apiuser, repoid, settings):
1839 1841 """
1840 1842 Update repository settings. Returns true on success.
1841 1843
1842 1844 :param apiuser: This is filled automatically from the |authtoken|.
1843 1845 :type apiuser: AuthUser
1844 1846 :param repoid: The repository name or repository id.
1845 1847 :type repoid: str or int
1846 1848 :param settings: The new settings for the repository.
1847 1849 :type: settings: dict
1848 1850
1849 1851 Example output:
1850 1852
1851 1853 .. code-block:: bash
1852 1854
1853 1855 {
1854 1856 "error": null,
1855 1857 "id": 237,
1856 1858 "result": true
1857 1859 }
1858 1860 """
1859 1861 # Restrict access to this api method to admins only.
1860 1862 if not has_superadmin_permission(apiuser):
1861 1863 raise JSONRPCForbidden()
1862 1864
1863 1865 if type(settings) is not dict:
1864 1866 raise JSONRPCError('Settings have to be a JSON Object.')
1865 1867
1866 1868 try:
1867 1869 settings_model = VcsSettingsModel(repo=repoid)
1868 1870
1869 1871 # Merge global, repo and incoming settings.
1870 1872 new_settings = settings_model.get_global_settings()
1871 1873 new_settings.update(settings_model.get_repo_settings())
1872 1874 new_settings.update(settings)
1873 1875
1874 1876 # Update the settings.
1875 1877 inherit_global_settings = new_settings.get(
1876 1878 'inherit_global_settings', False)
1877 1879 settings_model.create_or_update_repo_settings(
1878 1880 new_settings, inherit_global_settings=inherit_global_settings)
1879 1881 Session().commit()
1880 1882 except Exception:
1881 1883 msg = 'Failed to update settings for repository `{}`'.format(repoid)
1882 1884 log.exception(msg)
1883 1885 raise JSONRPCError(msg)
1884 1886
1885 1887 # Indicate success.
1886 1888 return True
@@ -1,464 +1,465 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2016 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 commit controller for RhodeCode showing changes between commits
23 23 """
24 24
25 25 import logging
26 26
27 27 from collections import defaultdict
28 28 from webob.exc import HTTPForbidden, HTTPBadRequest, HTTPNotFound
29 29
30 30 from pylons import tmpl_context as c, request, response
31 31 from pylons.i18n.translation import _
32 32 from pylons.controllers.util import redirect
33 33
34 34 from rhodecode.lib import auth
35 35 from rhodecode.lib import diffs
36 36 from rhodecode.lib.auth import (
37 37 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous)
38 38 from rhodecode.lib.base import BaseRepoController, render
39 39 from rhodecode.lib.compat import OrderedDict
40 40 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError
41 41 import rhodecode.lib.helpers as h
42 42 from rhodecode.lib.utils import action_logger, jsonify
43 43 from rhodecode.lib.utils2 import safe_unicode
44 44 from rhodecode.lib.vcs.backends.base import EmptyCommit
45 45 from rhodecode.lib.vcs.exceptions import (
46 46 RepositoryError, CommitDoesNotExistError)
47 47 from rhodecode.model.db import ChangesetComment, ChangesetStatus
48 48 from rhodecode.model.changeset_status import ChangesetStatusModel
49 49 from rhodecode.model.comment import ChangesetCommentsModel
50 50 from rhodecode.model.meta import Session
51 51 from rhodecode.model.repo import RepoModel
52 52
53 53
54 54 log = logging.getLogger(__name__)
55 55
56 56
57 57 def _update_with_GET(params, GET):
58 58 for k in ['diff1', 'diff2', 'diff']:
59 59 params[k] += GET.getall(k)
60 60
61 61
62 62 def get_ignore_ws(fid, GET):
63 63 ig_ws_global = GET.get('ignorews')
64 64 ig_ws = filter(lambda k: k.startswith('WS'), GET.getall(fid))
65 65 if ig_ws:
66 66 try:
67 67 return int(ig_ws[0].split(':')[-1])
68 68 except Exception:
69 69 pass
70 70 return ig_ws_global
71 71
72 72
73 73 def _ignorews_url(GET, fileid=None):
74 74 fileid = str(fileid) if fileid else None
75 75 params = defaultdict(list)
76 76 _update_with_GET(params, GET)
77 77 label = _('Show whitespace')
78 78 tooltiplbl = _('Show whitespace for all diffs')
79 79 ig_ws = get_ignore_ws(fileid, GET)
80 80 ln_ctx = get_line_ctx(fileid, GET)
81 81
82 82 if ig_ws is None:
83 83 params['ignorews'] += [1]
84 84 label = _('Ignore whitespace')
85 85 tooltiplbl = _('Ignore whitespace for all diffs')
86 86 ctx_key = 'context'
87 87 ctx_val = ln_ctx
88 88
89 89 # if we have passed in ln_ctx pass it along to our params
90 90 if ln_ctx:
91 91 params[ctx_key] += [ctx_val]
92 92
93 93 if fileid:
94 94 params['anchor'] = 'a_' + fileid
95 95 return h.link_to(label, h.url.current(**params), title=tooltiplbl, class_='tooltip')
96 96
97 97
98 98 def get_line_ctx(fid, GET):
99 99 ln_ctx_global = GET.get('context')
100 100 if fid:
101 101 ln_ctx = filter(lambda k: k.startswith('C'), GET.getall(fid))
102 102 else:
103 103 _ln_ctx = filter(lambda k: k.startswith('C'), GET)
104 104 ln_ctx = GET.get(_ln_ctx[0]) if _ln_ctx else ln_ctx_global
105 105 if ln_ctx:
106 106 ln_ctx = [ln_ctx]
107 107
108 108 if ln_ctx:
109 109 retval = ln_ctx[0].split(':')[-1]
110 110 else:
111 111 retval = ln_ctx_global
112 112
113 113 try:
114 114 return int(retval)
115 115 except Exception:
116 116 return 3
117 117
118 118
119 119 def _context_url(GET, fileid=None):
120 120 """
121 121 Generates a url for context lines.
122 122
123 123 :param fileid:
124 124 """
125 125
126 126 fileid = str(fileid) if fileid else None
127 127 ig_ws = get_ignore_ws(fileid, GET)
128 128 ln_ctx = (get_line_ctx(fileid, GET) or 3) * 2
129 129
130 130 params = defaultdict(list)
131 131 _update_with_GET(params, GET)
132 132
133 133 if ln_ctx > 0:
134 134 params['context'] += [ln_ctx]
135 135
136 136 if ig_ws:
137 137 ig_ws_key = 'ignorews'
138 138 ig_ws_val = 1
139 139 params[ig_ws_key] += [ig_ws_val]
140 140
141 141 lbl = _('Increase context')
142 142 tooltiplbl = _('Increase context for all diffs')
143 143
144 144 if fileid:
145 145 params['anchor'] = 'a_' + fileid
146 146 return h.link_to(lbl, h.url.current(**params), title=tooltiplbl, class_='tooltip')
147 147
148 148
149 149 class ChangesetController(BaseRepoController):
150 150
151 151 def __before__(self):
152 152 super(ChangesetController, self).__before__()
153 153 c.affected_files_cut_off = 60
154 154
155 155 def _index(self, commit_id_range, method):
156 156 c.ignorews_url = _ignorews_url
157 157 c.context_url = _context_url
158 158 c.fulldiff = fulldiff = request.GET.get('fulldiff')
159 159 # get ranges of commit ids if preset
160 160 commit_range = commit_id_range.split('...')[:2]
161 161 enable_comments = True
162 162 try:
163 163 pre_load = ['affected_files', 'author', 'branch', 'date',
164 164 'message', 'parents']
165 165
166 166 if len(commit_range) == 2:
167 167 enable_comments = False
168 168 commits = c.rhodecode_repo.get_commits(
169 169 start_id=commit_range[0], end_id=commit_range[1],
170 170 pre_load=pre_load)
171 171 commits = list(commits)
172 172 else:
173 173 commits = [c.rhodecode_repo.get_commit(
174 174 commit_id=commit_id_range, pre_load=pre_load)]
175 175
176 176 c.commit_ranges = commits
177 177 if not c.commit_ranges:
178 178 raise RepositoryError(
179 179 'The commit range returned an empty result')
180 180 except CommitDoesNotExistError:
181 181 msg = _('No such commit exists for this repository')
182 182 h.flash(msg, category='error')
183 183 raise HTTPNotFound()
184 184 except Exception:
185 185 log.exception("General failure")
186 186 raise HTTPNotFound()
187 187
188 188 c.changes = OrderedDict()
189 189 c.lines_added = 0
190 190 c.lines_deleted = 0
191 191
192 192 c.commit_statuses = ChangesetStatus.STATUSES
193 193 c.comments = []
194 194 c.statuses = []
195 195 c.inline_comments = []
196 196 c.inline_cnt = 0
197 197 c.files = []
198 198
199 199 # Iterate over ranges (default commit view is always one commit)
200 200 for commit in c.commit_ranges:
201 201 if method == 'show':
202 202 c.statuses.extend([ChangesetStatusModel().get_status(
203 203 c.rhodecode_db_repo.repo_id, commit.raw_id)])
204 204
205 205 c.comments.extend(ChangesetCommentsModel().get_comments(
206 206 c.rhodecode_db_repo.repo_id,
207 207 revision=commit.raw_id))
208 208
209 209 # comments from PR
210 210 st = ChangesetStatusModel().get_statuses(
211 211 c.rhodecode_db_repo.repo_id, commit.raw_id,
212 212 with_revisions=True)
213 213
214 214 # from associated statuses, check the pull requests, and
215 215 # show comments from them
216 216
217 217 prs = set(x.pull_request for x in
218 218 filter(lambda x: x.pull_request is not None, st))
219 219 for pr in prs:
220 220 c.comments.extend(pr.comments)
221 221
222 222 inlines = ChangesetCommentsModel().get_inline_comments(
223 223 c.rhodecode_db_repo.repo_id, revision=commit.raw_id)
224 224 c.inline_comments.extend(inlines.iteritems())
225 225
226 226 c.changes[commit.raw_id] = []
227 227
228 228 commit2 = commit
229 229 commit1 = commit.parents[0] if commit.parents else EmptyCommit()
230 230
231 231 # fetch global flags of ignore ws or context lines
232 232 context_lcl = get_line_ctx('', request.GET)
233 233 ign_whitespace_lcl = get_ignore_ws('', request.GET)
234 234
235 235 _diff = c.rhodecode_repo.get_diff(
236 236 commit1, commit2,
237 237 ignore_whitespace=ign_whitespace_lcl, context=context_lcl)
238 238
239 239 # diff_limit will cut off the whole diff if the limit is applied
240 240 # otherwise it will just hide the big files from the front-end
241 241 diff_limit = self.cut_off_limit_diff
242 242 file_limit = self.cut_off_limit_file
243 243
244 244 diff_processor = diffs.DiffProcessor(
245 245 _diff, format='gitdiff', diff_limit=diff_limit,
246 246 file_limit=file_limit, show_full_diff=fulldiff)
247 247 commit_changes = OrderedDict()
248 248 if method == 'show':
249 249 _parsed = diff_processor.prepare()
250 250 c.limited_diff = isinstance(_parsed, diffs.LimitedDiffContainer)
251 251 for f in _parsed:
252 252 c.files.append(f)
253 253 st = f['stats']
254 254 c.lines_added += st['added']
255 255 c.lines_deleted += st['deleted']
256 256 fid = h.FID(commit.raw_id, f['filename'])
257 257 diff = diff_processor.as_html(enable_comments=enable_comments,
258 258 parsed_lines=[f])
259 259 commit_changes[fid] = [
260 260 commit1.raw_id, commit2.raw_id,
261 261 f['operation'], f['filename'], diff, st, f]
262 262 else:
263 263 # downloads/raw we only need RAW diff nothing else
264 264 diff = diff_processor.as_raw()
265 265 commit_changes[''] = [None, None, None, None, diff, None, None]
266 266 c.changes[commit.raw_id] = commit_changes
267 267
268 268 # sort comments by how they were generated
269 269 c.comments = sorted(c.comments, key=lambda x: x.comment_id)
270 270
271 271 # count inline comments
272 272 for __, lines in c.inline_comments:
273 273 for comments in lines.values():
274 274 c.inline_cnt += len(comments)
275 275
276 276 if len(c.commit_ranges) == 1:
277 277 c.commit = c.commit_ranges[0]
278 278 c.parent_tmpl = ''.join(
279 279 '# Parent %s\n' % x.raw_id for x in c.commit.parents)
280 280 if method == 'download':
281 281 response.content_type = 'text/plain'
282 282 response.content_disposition = (
283 283 'attachment; filename=%s.diff' % commit_id_range[:12])
284 284 return diff
285 285 elif method == 'patch':
286 286 response.content_type = 'text/plain'
287 287 c.diff = safe_unicode(diff)
288 288 return render('changeset/patch_changeset.html')
289 289 elif method == 'raw':
290 290 response.content_type = 'text/plain'
291 291 return diff
292 292 elif method == 'show':
293 293 if len(c.commit_ranges) == 1:
294 294 return render('changeset/changeset.html')
295 295 else:
296 296 c.ancestor = None
297 297 c.target_repo = c.rhodecode_db_repo
298 298 return render('changeset/changeset_range.html')
299 299
300 300 @LoginRequired()
301 301 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
302 302 'repository.admin')
303 303 def index(self, revision, method='show'):
304 304 return self._index(revision, method=method)
305 305
306 306 @LoginRequired()
307 307 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
308 308 'repository.admin')
309 309 def changeset_raw(self, revision):
310 310 return self._index(revision, method='raw')
311 311
312 312 @LoginRequired()
313 313 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
314 314 'repository.admin')
315 315 def changeset_patch(self, revision):
316 316 return self._index(revision, method='patch')
317 317
318 318 @LoginRequired()
319 319 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
320 320 'repository.admin')
321 321 def changeset_download(self, revision):
322 322 return self._index(revision, method='download')
323 323
324 324 @LoginRequired()
325 325 @NotAnonymous()
326 326 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
327 327 'repository.admin')
328 328 @auth.CSRFRequired()
329 329 @jsonify
330 330 def comment(self, repo_name, revision):
331 331 commit_id = revision
332 332 status = request.POST.get('changeset_status', None)
333 333 text = request.POST.get('text')
334 334 if status:
335 335 text = text or (_('Status change %(transition_icon)s %(status)s')
336 336 % {'transition_icon': '>',
337 337 'status': ChangesetStatus.get_status_lbl(status)})
338 338
339 339 multi_commit_ids = filter(
340 340 lambda s: s not in ['', None],
341 341 request.POST.get('commit_ids', '').split(','),)
342 342
343 343 commit_ids = multi_commit_ids or [commit_id]
344 344 comment = None
345 345 for current_id in filter(None, commit_ids):
346 346 c.co = comment = ChangesetCommentsModel().create(
347 347 text=text,
348 348 repo=c.rhodecode_db_repo.repo_id,
349 349 user=c.rhodecode_user.user_id,
350 350 revision=current_id,
351 351 f_path=request.POST.get('f_path'),
352 352 line_no=request.POST.get('line'),
353 353 status_change=(ChangesetStatus.get_status_lbl(status)
354 if status else None)
354 if status else None),
355 status_change_type=status
355 356 )
356 357 # get status if set !
357 358 if status:
358 359 # if latest status was from pull request and it's closed
359 360 # disallow changing status !
360 361 # dont_allow_on_closed_pull_request = True !
361 362
362 363 try:
363 364 ChangesetStatusModel().set_status(
364 365 c.rhodecode_db_repo.repo_id,
365 366 status,
366 367 c.rhodecode_user.user_id,
367 368 comment,
368 369 revision=current_id,
369 370 dont_allow_on_closed_pull_request=True
370 371 )
371 372 except StatusChangeOnClosedPullRequestError:
372 373 msg = _('Changing the status of a commit associated with '
373 374 'a closed pull request is not allowed')
374 375 log.exception(msg)
375 376 h.flash(msg, category='warning')
376 377 return redirect(h.url(
377 378 'changeset_home', repo_name=repo_name,
378 379 revision=current_id))
379 380
380 381 # finalize, commit and redirect
381 382 Session().commit()
382 383
383 384 data = {
384 385 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
385 386 }
386 387 if comment:
387 388 data.update(comment.get_dict())
388 389 data.update({'rendered_text':
389 390 render('changeset/changeset_comment_block.html')})
390 391
391 392 return data
392 393
393 394 @LoginRequired()
394 395 @NotAnonymous()
395 396 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
396 397 'repository.admin')
397 398 @auth.CSRFRequired()
398 399 def preview_comment(self):
399 400 # Technically a CSRF token is not needed as no state changes with this
400 401 # call. However, as this is a POST is better to have it, so automated
401 402 # tools don't flag it as potential CSRF.
402 403 # Post is required because the payload could be bigger than the maximum
403 404 # allowed by GET.
404 405 if not request.environ.get('HTTP_X_PARTIAL_XHR'):
405 406 raise HTTPBadRequest()
406 407 text = request.POST.get('text')
407 408 renderer = request.POST.get('renderer') or 'rst'
408 409 if text:
409 410 return h.render(text, renderer=renderer, mentions=True)
410 411 return ''
411 412
412 413 @LoginRequired()
413 414 @NotAnonymous()
414 415 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
415 416 'repository.admin')
416 417 @auth.CSRFRequired()
417 418 @jsonify
418 419 def delete_comment(self, repo_name, comment_id):
419 420 comment = ChangesetComment.get(comment_id)
420 421 owner = (comment.author.user_id == c.rhodecode_user.user_id)
421 422 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
422 423 if h.HasPermissionAny('hg.admin')() or is_repo_admin or owner:
423 424 ChangesetCommentsModel().delete(comment=comment)
424 425 Session().commit()
425 426 return True
426 427 else:
427 428 raise HTTPForbidden()
428 429
429 430 @LoginRequired()
430 431 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
431 432 'repository.admin')
432 433 @jsonify
433 434 def changeset_info(self, repo_name, revision):
434 435 if request.is_xhr:
435 436 try:
436 437 return c.rhodecode_repo.get_commit(commit_id=revision)
437 438 except CommitDoesNotExistError as e:
438 439 return EmptyCommit(message=str(e))
439 440 else:
440 441 raise HTTPBadRequest()
441 442
442 443 @LoginRequired()
443 444 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
444 445 'repository.admin')
445 446 @jsonify
446 447 def changeset_children(self, repo_name, revision):
447 448 if request.is_xhr:
448 449 commit = c.rhodecode_repo.get_commit(commit_id=revision)
449 450 result = {"results": commit.children}
450 451 return result
451 452 else:
452 453 raise HTTPBadRequest()
453 454
454 455 @LoginRequired()
455 456 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
456 457 'repository.admin')
457 458 @jsonify
458 459 def changeset_parents(self, repo_name, revision):
459 460 if request.is_xhr:
460 461 commit = c.rhodecode_repo.get_commit(commit_id=revision)
461 462 result = {"results": commit.parents}
462 463 return result
463 464 else:
464 465 raise HTTPBadRequest()
@@ -1,853 +1,855 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2016 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 pull requests controller for rhodecode for initializing pull requests
23 23 """
24 24
25 25 import formencode
26 26 import logging
27 27
28 28 from webob.exc import HTTPNotFound, HTTPForbidden, HTTPBadRequest
29 29 from pylons import request, tmpl_context as c, url
30 30 from pylons.controllers.util import redirect
31 31 from pylons.i18n.translation import _
32 32 from sqlalchemy.sql import func
33 33 from sqlalchemy.sql.expression import or_
34 34
35 35 from rhodecode import events
36 36 from rhodecode.lib import auth, diffs, helpers as h
37 37 from rhodecode.lib.ext_json import json
38 38 from rhodecode.lib.base import (
39 39 BaseRepoController, render, vcs_operation_context)
40 40 from rhodecode.lib.auth import (
41 41 LoginRequired, HasRepoPermissionAnyDecorator, NotAnonymous,
42 42 HasAcceptedRepoType, XHRRequired)
43 43 from rhodecode.lib.utils import jsonify
44 44 from rhodecode.lib.utils2 import safe_int, safe_str, str2bool, safe_unicode
45 45 from rhodecode.lib.vcs.backends.base import EmptyCommit
46 46 from rhodecode.lib.vcs.exceptions import (
47 47 EmptyRepositoryError, CommitDoesNotExistError, RepositoryRequirementError)
48 48 from rhodecode.lib.diffs import LimitedDiffContainer
49 49 from rhodecode.model.changeset_status import ChangesetStatusModel
50 50 from rhodecode.model.comment import ChangesetCommentsModel
51 51 from rhodecode.model.db import PullRequest, ChangesetStatus, ChangesetComment, \
52 52 Repository
53 53 from rhodecode.model.forms import PullRequestForm
54 54 from rhodecode.model.meta import Session
55 55 from rhodecode.model.pull_request import PullRequestModel
56 56
57 57 log = logging.getLogger(__name__)
58 58
59 59
60 60 class PullrequestsController(BaseRepoController):
61 61 def __before__(self):
62 62 super(PullrequestsController, self).__before__()
63 63
64 64 def _load_compare_data(self, pull_request, enable_comments=True):
65 65 """
66 66 Load context data needed for generating compare diff
67 67
68 68 :param pull_request: object related to the request
69 69 :param enable_comments: flag to determine if comments are included
70 70 """
71 71 source_repo = pull_request.source_repo
72 72 source_ref_id = pull_request.source_ref_parts.commit_id
73 73
74 74 target_repo = pull_request.target_repo
75 75 target_ref_id = pull_request.target_ref_parts.commit_id
76 76
77 77 # despite opening commits for bookmarks/branches/tags, we always
78 78 # convert this to rev to prevent changes after bookmark or branch change
79 79 c.source_ref_type = 'rev'
80 80 c.source_ref = source_ref_id
81 81
82 82 c.target_ref_type = 'rev'
83 83 c.target_ref = target_ref_id
84 84
85 85 c.source_repo = source_repo
86 86 c.target_repo = target_repo
87 87
88 88 c.fulldiff = bool(request.GET.get('fulldiff'))
89 89
90 90 # diff_limit is the old behavior, will cut off the whole diff
91 91 # if the limit is applied otherwise will just hide the
92 92 # big files from the front-end
93 93 diff_limit = self.cut_off_limit_diff
94 94 file_limit = self.cut_off_limit_file
95 95
96 96 pre_load = ["author", "branch", "date", "message"]
97 97
98 98 c.commit_ranges = []
99 99 source_commit = EmptyCommit()
100 100 target_commit = EmptyCommit()
101 101 c.missing_requirements = False
102 102 try:
103 103 c.commit_ranges = [
104 104 source_repo.get_commit(commit_id=rev, pre_load=pre_load)
105 105 for rev in pull_request.revisions]
106 106
107 107 c.statuses = source_repo.statuses(
108 108 [x.raw_id for x in c.commit_ranges])
109 109
110 110 target_commit = source_repo.get_commit(
111 111 commit_id=safe_str(target_ref_id))
112 112 source_commit = source_repo.get_commit(
113 113 commit_id=safe_str(source_ref_id))
114 114 except RepositoryRequirementError:
115 115 c.missing_requirements = True
116 116
117 117 c.missing_commits = False
118 118 if (c.missing_requirements or
119 119 isinstance(source_commit, EmptyCommit) or
120 120 source_commit == target_commit):
121 121 _parsed = []
122 122 c.missing_commits = True
123 123 else:
124 124 vcs_diff = PullRequestModel().get_diff(pull_request)
125 125 diff_processor = diffs.DiffProcessor(
126 126 vcs_diff, format='gitdiff', diff_limit=diff_limit,
127 127 file_limit=file_limit, show_full_diff=c.fulldiff)
128 128 _parsed = diff_processor.prepare()
129 129
130 130 c.limited_diff = isinstance(_parsed, LimitedDiffContainer)
131 131
132 132 c.files = []
133 133 c.changes = {}
134 134 c.lines_added = 0
135 135 c.lines_deleted = 0
136 136 c.included_files = []
137 137 c.deleted_files = []
138 138
139 139 for f in _parsed:
140 140 st = f['stats']
141 141 c.lines_added += st['added']
142 142 c.lines_deleted += st['deleted']
143 143
144 144 fid = h.FID('', f['filename'])
145 145 c.files.append([fid, f['operation'], f['filename'], f['stats']])
146 146 c.included_files.append(f['filename'])
147 147 html_diff = diff_processor.as_html(enable_comments=enable_comments,
148 148 parsed_lines=[f])
149 149 c.changes[fid] = [f['operation'], f['filename'], html_diff, f]
150 150
151 151 def _extract_ordering(self, request):
152 152 column_index = safe_int(request.GET.get('order[0][column]'))
153 153 order_dir = request.GET.get('order[0][dir]', 'desc')
154 154 order_by = request.GET.get(
155 155 'columns[%s][data][sort]' % column_index, 'name_raw')
156 156 return order_by, order_dir
157 157
158 158 @LoginRequired()
159 159 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
160 160 'repository.admin')
161 161 @HasAcceptedRepoType('git', 'hg')
162 162 def show_all(self, repo_name):
163 163 # filter types
164 164 c.active = 'open'
165 165 c.source = str2bool(request.GET.get('source'))
166 166 c.closed = str2bool(request.GET.get('closed'))
167 167 c.my = str2bool(request.GET.get('my'))
168 168 c.awaiting_review = str2bool(request.GET.get('awaiting_review'))
169 169 c.awaiting_my_review = str2bool(request.GET.get('awaiting_my_review'))
170 170 c.repo_name = repo_name
171 171
172 172 opened_by = None
173 173 if c.my:
174 174 c.active = 'my'
175 175 opened_by = [c.rhodecode_user.user_id]
176 176
177 177 statuses = [PullRequest.STATUS_NEW, PullRequest.STATUS_OPEN]
178 178 if c.closed:
179 179 c.active = 'closed'
180 180 statuses = [PullRequest.STATUS_CLOSED]
181 181
182 182 if c.awaiting_review and not c.source:
183 183 c.active = 'awaiting'
184 184 if c.source and not c.awaiting_review:
185 185 c.active = 'source'
186 186 if c.awaiting_my_review:
187 187 c.active = 'awaiting_my'
188 188
189 189 data = self._get_pull_requests_list(
190 190 repo_name=repo_name, opened_by=opened_by, statuses=statuses)
191 191 if not request.is_xhr:
192 192 c.data = json.dumps(data['data'])
193 193 c.records_total = data['recordsTotal']
194 194 return render('/pullrequests/pullrequests.html')
195 195 else:
196 196 return json.dumps(data)
197 197
198 198 def _get_pull_requests_list(self, repo_name, opened_by, statuses):
199 199 # pagination
200 200 start = safe_int(request.GET.get('start'), 0)
201 201 length = safe_int(request.GET.get('length'), c.visual.dashboard_items)
202 202 order_by, order_dir = self._extract_ordering(request)
203 203
204 204 if c.awaiting_review:
205 205 pull_requests = PullRequestModel().get_awaiting_review(
206 206 repo_name, source=c.source, opened_by=opened_by,
207 207 statuses=statuses, offset=start, length=length,
208 208 order_by=order_by, order_dir=order_dir)
209 209 pull_requests_total_count = PullRequestModel(
210 210 ).count_awaiting_review(
211 211 repo_name, source=c.source, statuses=statuses,
212 212 opened_by=opened_by)
213 213 elif c.awaiting_my_review:
214 214 pull_requests = PullRequestModel().get_awaiting_my_review(
215 215 repo_name, source=c.source, opened_by=opened_by,
216 216 user_id=c.rhodecode_user.user_id, statuses=statuses,
217 217 offset=start, length=length, order_by=order_by,
218 218 order_dir=order_dir)
219 219 pull_requests_total_count = PullRequestModel(
220 220 ).count_awaiting_my_review(
221 221 repo_name, source=c.source, user_id=c.rhodecode_user.user_id,
222 222 statuses=statuses, opened_by=opened_by)
223 223 else:
224 224 pull_requests = PullRequestModel().get_all(
225 225 repo_name, source=c.source, opened_by=opened_by,
226 226 statuses=statuses, offset=start, length=length,
227 227 order_by=order_by, order_dir=order_dir)
228 228 pull_requests_total_count = PullRequestModel().count_all(
229 229 repo_name, source=c.source, statuses=statuses,
230 230 opened_by=opened_by)
231 231
232 232 from rhodecode.lib.utils import PartialRenderer
233 233 _render = PartialRenderer('data_table/_dt_elements.html')
234 234 data = []
235 235 for pr in pull_requests:
236 236 comments = ChangesetCommentsModel().get_all_comments(
237 237 c.rhodecode_db_repo.repo_id, pull_request=pr)
238 238
239 239 data.append({
240 240 'name': _render('pullrequest_name',
241 241 pr.pull_request_id, pr.target_repo.repo_name),
242 242 'name_raw': pr.pull_request_id,
243 243 'status': _render('pullrequest_status',
244 244 pr.calculated_review_status()),
245 245 'title': _render(
246 246 'pullrequest_title', pr.title, pr.description),
247 247 'description': h.escape(pr.description),
248 248 'updated_on': _render('pullrequest_updated_on',
249 249 h.datetime_to_time(pr.updated_on)),
250 250 'updated_on_raw': h.datetime_to_time(pr.updated_on),
251 251 'created_on': _render('pullrequest_updated_on',
252 252 h.datetime_to_time(pr.created_on)),
253 253 'created_on_raw': h.datetime_to_time(pr.created_on),
254 254 'author': _render('pullrequest_author',
255 255 pr.author.full_contact, ),
256 256 'author_raw': pr.author.full_name,
257 257 'comments': _render('pullrequest_comments', len(comments)),
258 258 'comments_raw': len(comments),
259 259 'closed': pr.is_closed(),
260 260 })
261 261 # json used to render the grid
262 262 data = ({
263 263 'data': data,
264 264 'recordsTotal': pull_requests_total_count,
265 265 'recordsFiltered': pull_requests_total_count,
266 266 })
267 267 return data
268 268
269 269 @LoginRequired()
270 270 @NotAnonymous()
271 271 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
272 272 'repository.admin')
273 273 @HasAcceptedRepoType('git', 'hg')
274 274 def index(self):
275 275 source_repo = c.rhodecode_db_repo
276 276
277 277 try:
278 278 source_repo.scm_instance().get_commit()
279 279 except EmptyRepositoryError:
280 280 h.flash(h.literal(_('There are no commits yet')),
281 281 category='warning')
282 282 redirect(url('summary_home', repo_name=source_repo.repo_name))
283 283
284 284 commit_id = request.GET.get('commit')
285 285 branch_ref = request.GET.get('branch')
286 286 bookmark_ref = request.GET.get('bookmark')
287 287
288 288 try:
289 289 source_repo_data = PullRequestModel().generate_repo_data(
290 290 source_repo, commit_id=commit_id,
291 291 branch=branch_ref, bookmark=bookmark_ref)
292 292 except CommitDoesNotExistError as e:
293 293 log.exception(e)
294 294 h.flash(_('Commit does not exist'), 'error')
295 295 redirect(url('pullrequest_home', repo_name=source_repo.repo_name))
296 296
297 297 default_target_repo = source_repo
298 298 if (source_repo.parent and
299 299 not source_repo.parent.scm_instance().is_empty()):
300 300 # change default if we have a parent repo
301 301 default_target_repo = source_repo.parent
302 302
303 303 target_repo_data = PullRequestModel().generate_repo_data(
304 304 default_target_repo)
305 305
306 306 selected_source_ref = source_repo_data['refs']['selected_ref']
307 307
308 308 title_source_ref = selected_source_ref.split(':', 2)[1]
309 309 c.default_title = PullRequestModel().generate_pullrequest_title(
310 310 source=source_repo.repo_name,
311 311 source_ref=title_source_ref,
312 312 target=default_target_repo.repo_name
313 313 )
314 314
315 315 c.default_repo_data = {
316 316 'source_repo_name': source_repo.repo_name,
317 317 'source_refs_json': json.dumps(source_repo_data),
318 318 'target_repo_name': default_target_repo.repo_name,
319 319 'target_refs_json': json.dumps(target_repo_data),
320 320 }
321 321 c.default_source_ref = selected_source_ref
322 322
323 323 return render('/pullrequests/pullrequest.html')
324 324
325 325 @LoginRequired()
326 326 @NotAnonymous()
327 327 @XHRRequired()
328 328 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
329 329 'repository.admin')
330 330 @jsonify
331 331 def get_repo_refs(self, repo_name, target_repo_name):
332 332 repo = Repository.get_by_repo_name(target_repo_name)
333 333 if not repo:
334 334 raise HTTPNotFound
335 335 return PullRequestModel().generate_repo_data(repo)
336 336
337 337 @LoginRequired()
338 338 @NotAnonymous()
339 339 @XHRRequired()
340 340 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
341 341 'repository.admin')
342 342 @jsonify
343 343 def get_repo_destinations(self, repo_name):
344 344 repo = Repository.get_by_repo_name(repo_name)
345 345 if not repo:
346 346 raise HTTPNotFound
347 347 filter_query = request.GET.get('query')
348 348
349 349 query = Repository.query() \
350 350 .order_by(func.length(Repository.repo_name)) \
351 351 .filter(or_(
352 352 Repository.repo_name == repo.repo_name,
353 353 Repository.fork_id == repo.repo_id))
354 354
355 355 if filter_query:
356 356 ilike_expression = u'%{}%'.format(safe_unicode(filter_query))
357 357 query = query.filter(
358 358 Repository.repo_name.ilike(ilike_expression))
359 359
360 360 add_parent = False
361 361 if repo.parent:
362 362 if filter_query in repo.parent.repo_name:
363 363 if not repo.parent.scm_instance().is_empty():
364 364 add_parent = True
365 365
366 366 limit = 20 - 1 if add_parent else 20
367 367 all_repos = query.limit(limit).all()
368 368 if add_parent:
369 369 all_repos += [repo.parent]
370 370
371 371 repos = []
372 372 for obj in self.scm_model.get_repos(all_repos):
373 373 repos.append({
374 374 'id': obj['name'],
375 375 'text': obj['name'],
376 376 'type': 'repo',
377 377 'obj': obj['dbrepo']
378 378 })
379 379
380 380 data = {
381 381 'more': False,
382 382 'results': [{
383 383 'text': _('Repositories'),
384 384 'children': repos
385 385 }] if repos else []
386 386 }
387 387 return data
388 388
389 389 @LoginRequired()
390 390 @NotAnonymous()
391 391 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
392 392 'repository.admin')
393 393 @HasAcceptedRepoType('git', 'hg')
394 394 @auth.CSRFRequired()
395 395 def create(self, repo_name):
396 396 repo = Repository.get_by_repo_name(repo_name)
397 397 if not repo:
398 398 raise HTTPNotFound
399 399
400 400 try:
401 401 _form = PullRequestForm(repo.repo_id)().to_python(request.POST)
402 402 except formencode.Invalid as errors:
403 403 if errors.error_dict.get('revisions'):
404 404 msg = 'Revisions: %s' % errors.error_dict['revisions']
405 405 elif errors.error_dict.get('pullrequest_title'):
406 406 msg = _('Pull request requires a title with min. 3 chars')
407 407 else:
408 408 msg = _('Error creating pull request: {}').format(errors)
409 409 log.exception(msg)
410 410 h.flash(msg, 'error')
411 411
412 412 # would rather just go back to form ...
413 413 return redirect(url('pullrequest_home', repo_name=repo_name))
414 414
415 415 source_repo = _form['source_repo']
416 416 source_ref = _form['source_ref']
417 417 target_repo = _form['target_repo']
418 418 target_ref = _form['target_ref']
419 419 commit_ids = _form['revisions'][::-1]
420 420 reviewers = _form['review_members']
421 421
422 422 # find the ancestor for this pr
423 423 source_db_repo = Repository.get_by_repo_name(_form['source_repo'])
424 424 target_db_repo = Repository.get_by_repo_name(_form['target_repo'])
425 425
426 426 source_scm = source_db_repo.scm_instance()
427 427 target_scm = target_db_repo.scm_instance()
428 428
429 429 source_commit = source_scm.get_commit(source_ref.split(':')[-1])
430 430 target_commit = target_scm.get_commit(target_ref.split(':')[-1])
431 431
432 432 ancestor = source_scm.get_common_ancestor(
433 433 source_commit.raw_id, target_commit.raw_id, target_scm)
434 434
435 435 target_ref_type, target_ref_name, __ = _form['target_ref'].split(':')
436 436 target_ref = ':'.join((target_ref_type, target_ref_name, ancestor))
437 437
438 438 pullrequest_title = _form['pullrequest_title']
439 439 title_source_ref = source_ref.split(':', 2)[1]
440 440 if not pullrequest_title:
441 441 pullrequest_title = PullRequestModel().generate_pullrequest_title(
442 442 source=source_repo,
443 443 source_ref=title_source_ref,
444 444 target=target_repo
445 445 )
446 446
447 447 description = _form['pullrequest_desc']
448 448 try:
449 449 pull_request = PullRequestModel().create(
450 450 c.rhodecode_user.user_id, source_repo, source_ref, target_repo,
451 451 target_ref, commit_ids, reviewers, pullrequest_title,
452 452 description
453 453 )
454 454 Session().commit()
455 455 h.flash(_('Successfully opened new pull request'),
456 456 category='success')
457 457 except Exception as e:
458 458 msg = _('Error occurred during sending pull request')
459 459 log.exception(msg)
460 460 h.flash(msg, category='error')
461 461 return redirect(url('pullrequest_home', repo_name=repo_name))
462 462
463 463 return redirect(url('pullrequest_show', repo_name=target_repo,
464 464 pull_request_id=pull_request.pull_request_id))
465 465
466 466 @LoginRequired()
467 467 @NotAnonymous()
468 468 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
469 469 'repository.admin')
470 470 @auth.CSRFRequired()
471 471 @jsonify
472 472 def update(self, repo_name, pull_request_id):
473 473 pull_request_id = safe_int(pull_request_id)
474 474 pull_request = PullRequest.get_or_404(pull_request_id)
475 475 # only owner or admin can update it
476 476 allowed_to_update = PullRequestModel().check_user_update(
477 477 pull_request, c.rhodecode_user)
478 478 if allowed_to_update:
479 479 if 'reviewers_ids' in request.POST:
480 480 self._update_reviewers(pull_request_id)
481 481 elif str2bool(request.POST.get('update_commits', 'false')):
482 482 self._update_commits(pull_request)
483 483 elif str2bool(request.POST.get('close_pull_request', 'false')):
484 484 self._reject_close(pull_request)
485 485 elif str2bool(request.POST.get('edit_pull_request', 'false')):
486 486 self._edit_pull_request(pull_request)
487 487 else:
488 488 raise HTTPBadRequest()
489 489 return True
490 490 raise HTTPForbidden()
491 491
492 492 def _edit_pull_request(self, pull_request):
493 493 try:
494 494 PullRequestModel().edit(
495 495 pull_request, request.POST.get('title'),
496 496 request.POST.get('description'))
497 497 except ValueError:
498 498 msg = _(u'Cannot update closed pull requests.')
499 499 h.flash(msg, category='error')
500 500 return
501 501 else:
502 502 Session().commit()
503 503
504 504 msg = _(u'Pull request title & description updated.')
505 505 h.flash(msg, category='success')
506 506 return
507 507
508 508 def _update_commits(self, pull_request):
509 509 try:
510 510 if PullRequestModel().has_valid_update_type(pull_request):
511 511 updated_version, changes = PullRequestModel().update_commits(
512 512 pull_request)
513 513 if updated_version:
514 514 msg = _(
515 515 u'Pull request updated to "{source_commit_id}" with '
516 516 u'{count_added} added, {count_removed} removed '
517 517 u'commits.'
518 518 ).format(
519 519 source_commit_id=pull_request.source_ref_parts.commit_id,
520 520 count_added=len(changes.added),
521 521 count_removed=len(changes.removed))
522 522 h.flash(msg, category='success')
523 523 else:
524 524 h.flash(_("Nothing changed in pull request."),
525 525 category='warning')
526 526 else:
527 527 msg = _(
528 528 u"Skipping update of pull request due to reference "
529 529 u"type: {reference_type}"
530 530 ).format(reference_type=pull_request.source_ref_parts.type)
531 531 h.flash(msg, category='warning')
532 532 except CommitDoesNotExistError:
533 533 h.flash(
534 534 _(u'Update failed due to missing commits.'), category='error')
535 535
536 536 @auth.CSRFRequired()
537 537 @LoginRequired()
538 538 @NotAnonymous()
539 539 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
540 540 'repository.admin')
541 541 def merge(self, repo_name, pull_request_id):
542 542 """
543 543 POST /{repo_name}/pull-request/{pull_request_id}
544 544
545 545 Merge will perform a server-side merge of the specified
546 546 pull request, if the pull request is approved and mergeable.
547 547 After succesfull merging, the pull request is automatically
548 548 closed, with a relevant comment.
549 549 """
550 550 pull_request_id = safe_int(pull_request_id)
551 551 pull_request = PullRequest.get_or_404(pull_request_id)
552 552 user = c.rhodecode_user
553 553
554 554 if self._meets_merge_pre_conditions(pull_request, user):
555 555 log.debug("Pre-conditions checked, trying to merge.")
556 556 extras = vcs_operation_context(
557 557 request.environ, repo_name=pull_request.target_repo.repo_name,
558 558 username=user.username, action='push',
559 559 scm=pull_request.target_repo.repo_type)
560 560 self._merge_pull_request(pull_request, user, extras)
561 561
562 562 return redirect(url(
563 563 'pullrequest_show',
564 564 repo_name=pull_request.target_repo.repo_name,
565 565 pull_request_id=pull_request.pull_request_id))
566 566
567 567 def _meets_merge_pre_conditions(self, pull_request, user):
568 568 if not PullRequestModel().check_user_merge(pull_request, user):
569 569 raise HTTPForbidden()
570 570
571 571 merge_status, msg = PullRequestModel().merge_status(pull_request)
572 572 if not merge_status:
573 573 log.debug("Cannot merge, not mergeable.")
574 574 h.flash(msg, category='error')
575 575 return False
576 576
577 577 if (pull_request.calculated_review_status()
578 578 is not ChangesetStatus.STATUS_APPROVED):
579 579 log.debug("Cannot merge, approval is pending.")
580 580 msg = _('Pull request reviewer approval is pending.')
581 581 h.flash(msg, category='error')
582 582 return False
583 583 return True
584 584
585 585 def _merge_pull_request(self, pull_request, user, extras):
586 586 merge_resp = PullRequestModel().merge(
587 587 pull_request, user, extras=extras)
588 588
589 589 if merge_resp.executed:
590 590 log.debug("The merge was successful, closing the pull request.")
591 591 PullRequestModel().close_pull_request(
592 592 pull_request.pull_request_id, user)
593 593 Session().commit()
594 594 msg = _('Pull request was successfully merged and closed.')
595 595 h.flash(msg, category='success')
596 596 else:
597 597 log.debug(
598 598 "The merge was not successful. Merge response: %s",
599 599 merge_resp)
600 600 msg = PullRequestModel().merge_status_message(
601 601 merge_resp.failure_reason)
602 602 h.flash(msg, category='error')
603 603
604 604 def _update_reviewers(self, pull_request_id):
605 605 reviewers_ids = map(int, filter(
606 606 lambda v: v not in [None, ''],
607 607 request.POST.get('reviewers_ids', '').split(',')))
608 608 PullRequestModel().update_reviewers(pull_request_id, reviewers_ids)
609 609 Session().commit()
610 610
611 611 def _reject_close(self, pull_request):
612 612 if pull_request.is_closed():
613 613 raise HTTPForbidden()
614 614
615 615 PullRequestModel().close_pull_request_with_comment(
616 616 pull_request, c.rhodecode_user, c.rhodecode_db_repo)
617 617 Session().commit()
618 618
619 619 @LoginRequired()
620 620 @NotAnonymous()
621 621 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
622 622 'repository.admin')
623 623 @auth.CSRFRequired()
624 624 @jsonify
625 625 def delete(self, repo_name, pull_request_id):
626 626 pull_request_id = safe_int(pull_request_id)
627 627 pull_request = PullRequest.get_or_404(pull_request_id)
628 628 # only owner can delete it !
629 629 if pull_request.author.user_id == c.rhodecode_user.user_id:
630 630 PullRequestModel().delete(pull_request)
631 631 Session().commit()
632 632 h.flash(_('Successfully deleted pull request'),
633 633 category='success')
634 634 return redirect(url('my_account_pullrequests'))
635 635 raise HTTPForbidden()
636 636
637 637 @LoginRequired()
638 638 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
639 639 'repository.admin')
640 640 def show(self, repo_name, pull_request_id):
641 641 pull_request_id = safe_int(pull_request_id)
642 642 c.pull_request = PullRequest.get_or_404(pull_request_id)
643 643
644 644 c.template_context['pull_request_data']['pull_request_id'] = \
645 645 pull_request_id
646 646
647 647 # pull_requests repo_name we opened it against
648 648 # ie. target_repo must match
649 649 if repo_name != c.pull_request.target_repo.repo_name:
650 650 raise HTTPNotFound
651 651
652 652 c.allowed_to_change_status = PullRequestModel(). \
653 653 check_user_change_status(c.pull_request, c.rhodecode_user)
654 654 c.allowed_to_update = PullRequestModel().check_user_update(
655 655 c.pull_request, c.rhodecode_user) and not c.pull_request.is_closed()
656 656 c.allowed_to_merge = PullRequestModel().check_user_merge(
657 657 c.pull_request, c.rhodecode_user) and not c.pull_request.is_closed()
658 658
659 659 cc_model = ChangesetCommentsModel()
660 660
661 661 c.pull_request_reviewers = c.pull_request.reviewers_statuses()
662 662
663 663 c.pull_request_review_status = c.pull_request.calculated_review_status()
664 664 c.pr_merge_status, c.pr_merge_msg = PullRequestModel().merge_status(
665 665 c.pull_request)
666 666 c.approval_msg = None
667 667 if c.pull_request_review_status != ChangesetStatus.STATUS_APPROVED:
668 668 c.approval_msg = _('Reviewer approval is pending.')
669 669 c.pr_merge_status = False
670 670 # load compare data into template context
671 671 enable_comments = not c.pull_request.is_closed()
672 672 self._load_compare_data(c.pull_request, enable_comments=enable_comments)
673 673
674 674 # this is a hack to properly display links, when creating PR, the
675 675 # compare view and others uses different notation, and
676 676 # compare_commits.html renders links based on the target_repo.
677 677 # We need to swap that here to generate it properly on the html side
678 678 c.target_repo = c.source_repo
679 679
680 680 # inline comments
681 681 c.inline_cnt = 0
682 682 c.inline_comments = cc_model.get_inline_comments(
683 683 c.rhodecode_db_repo.repo_id,
684 684 pull_request=pull_request_id).items()
685 685 # count inline comments
686 686 for __, lines in c.inline_comments:
687 687 for comments in lines.values():
688 688 c.inline_cnt += len(comments)
689 689
690 690 # outdated comments
691 691 c.outdated_cnt = 0
692 692 if ChangesetCommentsModel.use_outdated_comments(c.pull_request):
693 693 c.outdated_comments = cc_model.get_outdated_comments(
694 694 c.rhodecode_db_repo.repo_id,
695 695 pull_request=c.pull_request)
696 696 # Count outdated comments and check for deleted files
697 697 for file_name, lines in c.outdated_comments.iteritems():
698 698 for comments in lines.values():
699 699 c.outdated_cnt += len(comments)
700 700 if file_name not in c.included_files:
701 701 c.deleted_files.append(file_name)
702 702 else:
703 703 c.outdated_comments = {}
704 704
705 705 # comments
706 706 c.comments = cc_model.get_comments(c.rhodecode_db_repo.repo_id,
707 707 pull_request=pull_request_id)
708 708
709 709 if c.allowed_to_update:
710 710 force_close = ('forced_closed', _('Close Pull Request'))
711 711 statuses = ChangesetStatus.STATUSES + [force_close]
712 712 else:
713 713 statuses = ChangesetStatus.STATUSES
714 714 c.commit_statuses = statuses
715 715
716 716 c.ancestor = None # TODO: add ancestor here
717 717
718 718 return render('/pullrequests/pullrequest_show.html')
719 719
720 720 @LoginRequired()
721 721 @NotAnonymous()
722 722 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
723 723 'repository.admin')
724 724 @auth.CSRFRequired()
725 725 @jsonify
726 726 def comment(self, repo_name, pull_request_id):
727 727 pull_request_id = safe_int(pull_request_id)
728 728 pull_request = PullRequest.get_or_404(pull_request_id)
729 729 if pull_request.is_closed():
730 730 raise HTTPForbidden()
731 731
732 732 # TODO: johbo: Re-think this bit, "approved_closed" does not exist
733 733 # as a changeset status, still we want to send it in one value.
734 734 status = request.POST.get('changeset_status', None)
735 735 text = request.POST.get('text')
736 736 if status and '_closed' in status:
737 737 close_pr = True
738 738 status = status.replace('_closed', '')
739 739 else:
740 740 close_pr = False
741 741
742 742 forced = (status == 'forced')
743 743 if forced:
744 744 status = 'rejected'
745 745
746 746 allowed_to_change_status = PullRequestModel().check_user_change_status(
747 747 pull_request, c.rhodecode_user)
748 748
749 749 if status and allowed_to_change_status:
750 750 message = (_('Status change %(transition_icon)s %(status)s')
751 751 % {'transition_icon': '>',
752 752 'status': ChangesetStatus.get_status_lbl(status)})
753 753 if close_pr:
754 754 message = _('Closing with') + ' ' + message
755 755 text = text or message
756 756 comm = ChangesetCommentsModel().create(
757 757 text=text,
758 758 repo=c.rhodecode_db_repo.repo_id,
759 759 user=c.rhodecode_user.user_id,
760 760 pull_request=pull_request_id,
761 761 f_path=request.POST.get('f_path'),
762 762 line_no=request.POST.get('line'),
763 763 status_change=(ChangesetStatus.get_status_lbl(status)
764 764 if status and allowed_to_change_status else None),
765 status_change_type=(status
766 if status and allowed_to_change_status else None),
765 767 closing_pr=close_pr
766 768 )
767 769
768 770
769 771
770 772 if allowed_to_change_status:
771 773 old_calculated_status = pull_request.calculated_review_status()
772 774 # get status if set !
773 775 if status:
774 776 ChangesetStatusModel().set_status(
775 777 c.rhodecode_db_repo.repo_id,
776 778 status,
777 779 c.rhodecode_user.user_id,
778 780 comm,
779 781 pull_request=pull_request_id
780 782 )
781 783
782 784 Session().flush()
783 785 events.trigger(events.PullRequestCommentEvent(pull_request, comm))
784 786 # we now calculate the status of pull request, and based on that
785 787 # calculation we set the commits status
786 788 calculated_status = pull_request.calculated_review_status()
787 789 if old_calculated_status != calculated_status:
788 790 PullRequestModel()._trigger_pull_request_hook(
789 791 pull_request, c.rhodecode_user, 'review_status_change')
790 792
791 793 calculated_status_lbl = ChangesetStatus.get_status_lbl(
792 794 calculated_status)
793 795
794 796 if close_pr:
795 797 status_completed = (
796 798 calculated_status in [ChangesetStatus.STATUS_APPROVED,
797 799 ChangesetStatus.STATUS_REJECTED])
798 800 if forced or status_completed:
799 801 PullRequestModel().close_pull_request(
800 802 pull_request_id, c.rhodecode_user)
801 803 else:
802 804 h.flash(_('Closing pull request on other statuses than '
803 805 'rejected or approved is forbidden. '
804 806 'Calculated status from all reviewers '
805 807 'is currently: %s') % calculated_status_lbl,
806 808 category='warning')
807 809
808 810 Session().commit()
809 811
810 812 if not request.is_xhr:
811 813 return redirect(h.url('pullrequest_show', repo_name=repo_name,
812 814 pull_request_id=pull_request_id))
813 815
814 816 data = {
815 817 'target_id': h.safeid(h.safe_unicode(request.POST.get('f_path'))),
816 818 }
817 819 if comm:
818 820 c.co = comm
819 821 data.update(comm.get_dict())
820 822 data.update({'rendered_text':
821 823 render('changeset/changeset_comment_block.html')})
822 824
823 825 return data
824 826
825 827 @LoginRequired()
826 828 @NotAnonymous()
827 829 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
828 830 'repository.admin')
829 831 @auth.CSRFRequired()
830 832 @jsonify
831 833 def delete_comment(self, repo_name, comment_id):
832 834 return self._delete_comment(comment_id)
833 835
834 836 def _delete_comment(self, comment_id):
835 837 comment_id = safe_int(comment_id)
836 838 co = ChangesetComment.get_or_404(comment_id)
837 839 if co.pull_request.is_closed():
838 840 # don't allow deleting comments on closed pull request
839 841 raise HTTPForbidden()
840 842
841 843 is_owner = co.author.user_id == c.rhodecode_user.user_id
842 844 is_repo_admin = h.HasRepoPermissionAny('repository.admin')(c.repo_name)
843 845 if h.HasPermissionAny('hg.admin')() or is_repo_admin or is_owner:
844 846 old_calculated_status = co.pull_request.calculated_review_status()
845 847 ChangesetCommentsModel().delete(comment=co)
846 848 Session().commit()
847 849 calculated_status = co.pull_request.calculated_review_status()
848 850 if old_calculated_status != calculated_status:
849 851 PullRequestModel()._trigger_pull_request_hook(
850 852 co.pull_request, c.rhodecode_user, 'review_status_change')
851 853 return True
852 854 else:
853 855 raise HTTPForbidden()
@@ -1,512 +1,515 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2016 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
25 25 import logging
26 26 import traceback
27 27 import collections
28 28
29 29 from datetime import datetime
30 30
31 31 from pylons.i18n.translation import _
32 32 from pyramid.threadlocal import get_current_registry
33 33 from sqlalchemy.sql.expression import null
34 34 from sqlalchemy.sql.functions import coalesce
35 35
36 36 from rhodecode.lib import helpers as h, diffs
37 37 from rhodecode.lib.channelstream import channelstream_request
38 38 from rhodecode.lib.utils import action_logger
39 39 from rhodecode.lib.utils2 import extract_mentioned_users
40 40 from rhodecode.model import BaseModel
41 41 from rhodecode.model.db import (
42 42 ChangesetComment, User, Notification, PullRequest)
43 43 from rhodecode.model.notification import NotificationModel
44 44 from rhodecode.model.meta import Session
45 45 from rhodecode.model.settings import VcsSettingsModel
46 46 from rhodecode.model.notification import EmailNotificationModel
47 47
48 48 log = logging.getLogger(__name__)
49 49
50 50
51 51 class ChangesetCommentsModel(BaseModel):
52 52
53 53 cls = ChangesetComment
54 54
55 55 DIFF_CONTEXT_BEFORE = 3
56 56 DIFF_CONTEXT_AFTER = 3
57 57
58 58 def __get_commit_comment(self, changeset_comment):
59 59 return self._get_instance(ChangesetComment, changeset_comment)
60 60
61 61 def __get_pull_request(self, pull_request):
62 62 return self._get_instance(PullRequest, pull_request)
63 63
64 64 def _extract_mentions(self, s):
65 65 user_objects = []
66 66 for username in extract_mentioned_users(s):
67 67 user_obj = User.get_by_username(username, case_insensitive=True)
68 68 if user_obj:
69 69 user_objects.append(user_obj)
70 70 return user_objects
71 71
72 72 def _get_renderer(self, global_renderer='rst'):
73 73 try:
74 74 # try reading from visual context
75 75 from pylons import tmpl_context
76 76 global_renderer = tmpl_context.visual.default_renderer
77 77 except AttributeError:
78 78 log.debug("Renderer not set, falling back "
79 79 "to default renderer '%s'", global_renderer)
80 80 except Exception:
81 81 log.error(traceback.format_exc())
82 82 return global_renderer
83 83
84 84 def create(self, text, repo, user, revision=None, pull_request=None,
85 f_path=None, line_no=None, status_change=None, closing_pr=False,
85 f_path=None, line_no=None, status_change=None,
86 status_change_type=None, closing_pr=False,
86 87 send_email=True, renderer=None):
87 88 """
88 89 Creates new comment for commit or pull request.
89 90 IF status_change is not none this comment is associated with a
90 91 status change of commit or commit associated with pull request
91 92
92 93 :param text:
93 94 :param repo:
94 95 :param user:
95 96 :param revision:
96 97 :param pull_request:
97 98 :param f_path:
98 99 :param line_no:
99 :param status_change:
100 :param status_change: Label for status change
101 :param status_change_type: type of status change
100 102 :param closing_pr:
101 103 :param send_email:
102 104 """
103 105 if not text:
104 106 log.warning('Missing text for comment, skipping...')
105 107 return
106 108
107 109 if not renderer:
108 110 renderer = self._get_renderer()
109 111
110 112 repo = self._get_repo(repo)
111 113 user = self._get_user(user)
112 114 comment = ChangesetComment()
113 115 comment.renderer = renderer
114 116 comment.repo = repo
115 117 comment.author = user
116 118 comment.text = text
117 119 comment.f_path = f_path
118 120 comment.line_no = line_no
119 121
120 122 #TODO (marcink): fix this and remove revision as param
121 123 commit_id = revision
122 124 pull_request_id = pull_request
123 125
124 126 commit_obj = None
125 127 pull_request_obj = None
126 128
127 129 if commit_id:
128 130 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
129 131 # do a lookup, so we don't pass something bad here
130 132 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
131 133 comment.revision = commit_obj.raw_id
132 134
133 135 elif pull_request_id:
134 136 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
135 137 pull_request_obj = self.__get_pull_request(pull_request_id)
136 138 comment.pull_request = pull_request_obj
137 139 else:
138 140 raise Exception('Please specify commit or pull_request_id')
139 141
140 142 Session().add(comment)
141 143 Session().flush()
142 144 kwargs = {
143 145 'user': user,
144 146 'renderer_type': renderer,
145 147 'repo_name': repo.repo_name,
146 148 'status_change': status_change,
149 'status_change_type': status_change_type,
147 150 'comment_body': text,
148 151 'comment_file': f_path,
149 152 'comment_line': line_no,
150 153 }
151 154
152 155 if commit_obj:
153 156 recipients = ChangesetComment.get_users(
154 157 revision=commit_obj.raw_id)
155 158 # add commit author if it's in RhodeCode system
156 159 cs_author = User.get_from_cs_author(commit_obj.author)
157 160 if not cs_author:
158 161 # use repo owner if we cannot extract the author correctly
159 162 cs_author = repo.user
160 163 recipients += [cs_author]
161 164
162 165 commit_comment_url = self.get_url(comment)
163 166
164 167 target_repo_url = h.link_to(
165 168 repo.repo_name,
166 169 h.url('summary_home',
167 170 repo_name=repo.repo_name, qualified=True))
168 171
169 172 # commit specifics
170 173 kwargs.update({
171 174 'commit': commit_obj,
172 175 'commit_message': commit_obj.message,
173 176 'commit_target_repo': target_repo_url,
174 177 'commit_comment_url': commit_comment_url,
175 178 })
176 179
177 180 elif pull_request_obj:
178 181 # get the current participants of this pull request
179 182 recipients = ChangesetComment.get_users(
180 183 pull_request_id=pull_request_obj.pull_request_id)
181 184 # add pull request author
182 185 recipients += [pull_request_obj.author]
183 186
184 187 # add the reviewers to notification
185 188 recipients += [x.user for x in pull_request_obj.reviewers]
186 189
187 190 pr_target_repo = pull_request_obj.target_repo
188 191 pr_source_repo = pull_request_obj.source_repo
189 192
190 193 pr_comment_url = h.url(
191 194 'pullrequest_show',
192 195 repo_name=pr_target_repo.repo_name,
193 196 pull_request_id=pull_request_obj.pull_request_id,
194 197 anchor='comment-%s' % comment.comment_id,
195 198 qualified=True,)
196 199
197 200 # set some variables for email notification
198 201 pr_target_repo_url = h.url(
199 202 'summary_home', repo_name=pr_target_repo.repo_name,
200 203 qualified=True)
201 204
202 205 pr_source_repo_url = h.url(
203 206 'summary_home', repo_name=pr_source_repo.repo_name,
204 207 qualified=True)
205 208
206 209 # pull request specifics
207 210 kwargs.update({
208 211 'pull_request': pull_request_obj,
209 212 'pr_id': pull_request_obj.pull_request_id,
210 213 'pr_target_repo': pr_target_repo,
211 214 'pr_target_repo_url': pr_target_repo_url,
212 215 'pr_source_repo': pr_source_repo,
213 216 'pr_source_repo_url': pr_source_repo_url,
214 217 'pr_comment_url': pr_comment_url,
215 218 'pr_closing': closing_pr,
216 219 })
217 220 if send_email:
218 221 # pre-generate the subject for notification itself
219 222 (subject,
220 223 _h, _e, # we don't care about those
221 224 body_plaintext) = EmailNotificationModel().render_email(
222 225 notification_type, **kwargs)
223 226
224 227 mention_recipients = set(
225 228 self._extract_mentions(text)).difference(recipients)
226 229
227 230 # create notification objects, and emails
228 231 NotificationModel().create(
229 232 created_by=user,
230 233 notification_subject=subject,
231 234 notification_body=body_plaintext,
232 235 notification_type=notification_type,
233 236 recipients=recipients,
234 237 mention_recipients=mention_recipients,
235 238 email_kwargs=kwargs,
236 239 )
237 240
238 241 action = (
239 242 'user_commented_pull_request:{}'.format(
240 243 comment.pull_request.pull_request_id)
241 244 if comment.pull_request
242 245 else 'user_commented_revision:{}'.format(comment.revision)
243 246 )
244 247 action_logger(user, action, comment.repo)
245 248
246 249 registry = get_current_registry()
247 250 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
248 251 channelstream_config = rhodecode_plugins.get('channelstream', {})
249 252 msg_url = ''
250 253 if commit_obj:
251 254 msg_url = commit_comment_url
252 255 repo_name = repo.repo_name
253 256 elif pull_request_obj:
254 257 msg_url = pr_comment_url
255 258 repo_name = pr_target_repo.repo_name
256 259
257 260 if channelstream_config.get('enabled'):
258 261 message = '<strong>{}</strong> {} - ' \
259 262 '<a onclick="window.location=\'{}\';' \
260 263 'window.location.reload()">' \
261 264 '<strong>{}</strong></a>'
262 265 message = message.format(
263 266 user.username, _('made a comment'), msg_url,
264 267 _('Refresh page'))
265 268 channel = '/repo${}$/pr/{}'.format(
266 269 repo_name,
267 270 pull_request_id
268 271 )
269 272 payload = {
270 273 'type': 'message',
271 274 'timestamp': datetime.utcnow(),
272 275 'user': 'system',
273 276 'exclude_users': [user.username],
274 277 'channel': channel,
275 278 'message': {
276 279 'message': message,
277 280 'level': 'info',
278 281 'topic': '/notifications'
279 282 }
280 283 }
281 284 channelstream_request(channelstream_config, [payload],
282 285 '/message', raise_exc=False)
283 286
284 287 return comment
285 288
286 289 def delete(self, comment):
287 290 """
288 291 Deletes given comment
289 292
290 293 :param comment_id:
291 294 """
292 295 comment = self.__get_commit_comment(comment)
293 296 Session().delete(comment)
294 297
295 298 return comment
296 299
297 300 def get_all_comments(self, repo_id, revision=None, pull_request=None):
298 301 q = ChangesetComment.query()\
299 302 .filter(ChangesetComment.repo_id == repo_id)
300 303 if revision:
301 304 q = q.filter(ChangesetComment.revision == revision)
302 305 elif pull_request:
303 306 pull_request = self.__get_pull_request(pull_request)
304 307 q = q.filter(ChangesetComment.pull_request == pull_request)
305 308 else:
306 309 raise Exception('Please specify commit or pull_request')
307 310 q = q.order_by(ChangesetComment.created_on)
308 311 return q.all()
309 312
310 313 def get_url(self, comment):
311 314 comment = self.__get_commit_comment(comment)
312 315 if comment.pull_request:
313 316 return h.url(
314 317 'pullrequest_show',
315 318 repo_name=comment.pull_request.target_repo.repo_name,
316 319 pull_request_id=comment.pull_request.pull_request_id,
317 320 anchor='comment-%s' % comment.comment_id,
318 321 qualified=True,)
319 322 else:
320 323 return h.url(
321 324 'changeset_home',
322 325 repo_name=comment.repo.repo_name,
323 326 revision=comment.revision,
324 327 anchor='comment-%s' % comment.comment_id,
325 328 qualified=True,)
326 329
327 330 def get_comments(self, repo_id, revision=None, pull_request=None):
328 331 """
329 332 Gets main comments based on revision or pull_request_id
330 333
331 334 :param repo_id:
332 335 :param revision:
333 336 :param pull_request:
334 337 """
335 338
336 339 q = ChangesetComment.query()\
337 340 .filter(ChangesetComment.repo_id == repo_id)\
338 341 .filter(ChangesetComment.line_no == None)\
339 342 .filter(ChangesetComment.f_path == None)
340 343 if revision:
341 344 q = q.filter(ChangesetComment.revision == revision)
342 345 elif pull_request:
343 346 pull_request = self.__get_pull_request(pull_request)
344 347 q = q.filter(ChangesetComment.pull_request == pull_request)
345 348 else:
346 349 raise Exception('Please specify commit or pull_request')
347 350 q = q.order_by(ChangesetComment.created_on)
348 351 return q.all()
349 352
350 353 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
351 354 q = self._get_inline_comments_query(repo_id, revision, pull_request)
352 355 return self._group_comments_by_path_and_line_number(q)
353 356
354 357 def get_outdated_comments(self, repo_id, pull_request):
355 358 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
356 359 # of a pull request.
357 360 q = self._all_inline_comments_of_pull_request(pull_request)
358 361 q = q.filter(
359 362 ChangesetComment.display_state ==
360 363 ChangesetComment.COMMENT_OUTDATED
361 364 ).order_by(ChangesetComment.comment_id.asc())
362 365
363 366 return self._group_comments_by_path_and_line_number(q)
364 367
365 368 def _get_inline_comments_query(self, repo_id, revision, pull_request):
366 369 # TODO: johbo: Split this into two methods: One for PR and one for
367 370 # commit.
368 371 if revision:
369 372 q = Session().query(ChangesetComment).filter(
370 373 ChangesetComment.repo_id == repo_id,
371 374 ChangesetComment.line_no != null(),
372 375 ChangesetComment.f_path != null(),
373 376 ChangesetComment.revision == revision)
374 377
375 378 elif pull_request:
376 379 pull_request = self.__get_pull_request(pull_request)
377 380 if ChangesetCommentsModel.use_outdated_comments(pull_request):
378 381 q = self._visible_inline_comments_of_pull_request(pull_request)
379 382 else:
380 383 q = self._all_inline_comments_of_pull_request(pull_request)
381 384
382 385 else:
383 386 raise Exception('Please specify commit or pull_request_id')
384 387 q = q.order_by(ChangesetComment.comment_id.asc())
385 388 return q
386 389
387 390 def _group_comments_by_path_and_line_number(self, q):
388 391 comments = q.all()
389 392 paths = collections.defaultdict(lambda: collections.defaultdict(list))
390 393 for co in comments:
391 394 paths[co.f_path][co.line_no].append(co)
392 395 return paths
393 396
394 397 @classmethod
395 398 def needed_extra_diff_context(cls):
396 399 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
397 400
398 401 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
399 402 if not ChangesetCommentsModel.use_outdated_comments(pull_request):
400 403 return
401 404
402 405 comments = self._visible_inline_comments_of_pull_request(pull_request)
403 406 comments_to_outdate = comments.all()
404 407
405 408 for comment in comments_to_outdate:
406 409 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
407 410
408 411 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
409 412 diff_line = _parse_comment_line_number(comment.line_no)
410 413
411 414 try:
412 415 old_context = old_diff_proc.get_context_of_line(
413 416 path=comment.f_path, diff_line=diff_line)
414 417 new_context = new_diff_proc.get_context_of_line(
415 418 path=comment.f_path, diff_line=diff_line)
416 419 except (diffs.LineNotInDiffException,
417 420 diffs.FileNotInDiffException):
418 421 comment.display_state = ChangesetComment.COMMENT_OUTDATED
419 422 return
420 423
421 424 if old_context == new_context:
422 425 return
423 426
424 427 if self._should_relocate_diff_line(diff_line):
425 428 new_diff_lines = new_diff_proc.find_context(
426 429 path=comment.f_path, context=old_context,
427 430 offset=self.DIFF_CONTEXT_BEFORE)
428 431 if not new_diff_lines:
429 432 comment.display_state = ChangesetComment.COMMENT_OUTDATED
430 433 else:
431 434 new_diff_line = self._choose_closest_diff_line(
432 435 diff_line, new_diff_lines)
433 436 comment.line_no = _diff_to_comment_line_number(new_diff_line)
434 437 else:
435 438 comment.display_state = ChangesetComment.COMMENT_OUTDATED
436 439
437 440 def _should_relocate_diff_line(self, diff_line):
438 441 """
439 442 Checks if relocation shall be tried for the given `diff_line`.
440 443
441 444 If a comment points into the first lines, then we can have a situation
442 445 that after an update another line has been added on top. In this case
443 446 we would find the context still and move the comment around. This
444 447 would be wrong.
445 448 """
446 449 should_relocate = (
447 450 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
448 451 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
449 452 return should_relocate
450 453
451 454 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
452 455 candidate = new_diff_lines[0]
453 456 best_delta = _diff_line_delta(diff_line, candidate)
454 457 for new_diff_line in new_diff_lines[1:]:
455 458 delta = _diff_line_delta(diff_line, new_diff_line)
456 459 if delta < best_delta:
457 460 candidate = new_diff_line
458 461 best_delta = delta
459 462 return candidate
460 463
461 464 def _visible_inline_comments_of_pull_request(self, pull_request):
462 465 comments = self._all_inline_comments_of_pull_request(pull_request)
463 466 comments = comments.filter(
464 467 coalesce(ChangesetComment.display_state, '') !=
465 468 ChangesetComment.COMMENT_OUTDATED)
466 469 return comments
467 470
468 471 def _all_inline_comments_of_pull_request(self, pull_request):
469 472 comments = Session().query(ChangesetComment)\
470 473 .filter(ChangesetComment.line_no != None)\
471 474 .filter(ChangesetComment.f_path != None)\
472 475 .filter(ChangesetComment.pull_request == pull_request)
473 476 return comments
474 477
475 478 @staticmethod
476 479 def use_outdated_comments(pull_request):
477 480 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
478 481 settings = settings_model.get_general_settings()
479 482 return settings.get('rhodecode_use_outdated_comments', False)
480 483
481 484
482 485 def _parse_comment_line_number(line_no):
483 486 """
484 487 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
485 488 """
486 489 old_line = None
487 490 new_line = None
488 491 if line_no.startswith('o'):
489 492 old_line = int(line_no[1:])
490 493 elif line_no.startswith('n'):
491 494 new_line = int(line_no[1:])
492 495 else:
493 496 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
494 497 return diffs.DiffLineNumber(old_line, new_line)
495 498
496 499
497 500 def _diff_to_comment_line_number(diff_line):
498 501 if diff_line.new is not None:
499 502 return u'n{}'.format(diff_line.new)
500 503 elif diff_line.old is not None:
501 504 return u'o{}'.format(diff_line.old)
502 505 return u''
503 506
504 507
505 508 def _diff_line_delta(a, b):
506 509 if None not in (a.new, b.new):
507 510 return abs(a.new - b.new)
508 511 elif None not in (a.old, b.old):
509 512 return abs(a.old - b.old)
510 513 else:
511 514 raise ValueError(
512 515 "Cannot compute delta between {} and {}".format(a, b))
@@ -1,1154 +1,1155 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 """
23 23 pull request model for RhodeCode
24 24 """
25 25
26 26 from collections import namedtuple
27 27 import json
28 28 import logging
29 29 import datetime
30 30
31 31 from pylons.i18n.translation import _
32 32 from pylons.i18n.translation import lazy_ugettext
33 33
34 34 import rhodecode
35 35 from rhodecode.lib import helpers as h, hooks_utils, diffs
36 36 from rhodecode.lib.compat import OrderedDict
37 37 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
38 38 from rhodecode.lib.markup_renderer import (
39 39 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
40 40 from rhodecode.lib.utils import action_logger
41 41 from rhodecode.lib.utils2 import safe_unicode, safe_str, md5_safe
42 42 from rhodecode.lib.vcs.backends.base import (
43 43 Reference, MergeResponse, MergeFailureReason)
44 44 from rhodecode.lib.vcs.exceptions import (
45 45 CommitDoesNotExistError, EmptyRepositoryError)
46 46 from rhodecode.model import BaseModel
47 47 from rhodecode.model.changeset_status import ChangesetStatusModel
48 48 from rhodecode.model.comment import ChangesetCommentsModel
49 49 from rhodecode.model.db import (
50 50 PullRequest, PullRequestReviewers, Notification, ChangesetStatus,
51 51 PullRequestVersion, ChangesetComment)
52 52 from rhodecode.model.meta import Session
53 53 from rhodecode.model.notification import NotificationModel, \
54 54 EmailNotificationModel
55 55 from rhodecode.model.scm import ScmModel
56 56 from rhodecode.model.settings import VcsSettingsModel
57 57
58 58
59 59 log = logging.getLogger(__name__)
60 60
61 61
62 62 class PullRequestModel(BaseModel):
63 63
64 64 cls = PullRequest
65 65
66 66 DIFF_CONTEXT = 3
67 67
68 68 MERGE_STATUS_MESSAGES = {
69 69 MergeFailureReason.NONE: lazy_ugettext(
70 70 'This pull request can be automatically merged.'),
71 71 MergeFailureReason.UNKNOWN: lazy_ugettext(
72 72 'This pull request cannot be merged because of an unhandled'
73 73 ' exception.'),
74 74 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
75 75 'This pull request cannot be merged because of conflicts.'),
76 76 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
77 77 'This pull request could not be merged because push to target'
78 78 ' failed.'),
79 79 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
80 80 'This pull request cannot be merged because the target is not a'
81 81 ' head.'),
82 82 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
83 83 'This pull request cannot be merged because the source contains'
84 84 ' more branches than the target.'),
85 85 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
86 86 'This pull request cannot be merged because the target has'
87 87 ' multiple heads.'),
88 88 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
89 89 'This pull request cannot be merged because the target repository'
90 90 ' is locked.'),
91 91 MergeFailureReason.MISSING_COMMIT: lazy_ugettext(
92 92 'This pull request cannot be merged because the target or the '
93 93 'source reference is missing.'),
94 94 }
95 95
96 96 def __get_pull_request(self, pull_request):
97 97 return self._get_instance(PullRequest, pull_request)
98 98
99 99 def _check_perms(self, perms, pull_request, user, api=False):
100 100 if not api:
101 101 return h.HasRepoPermissionAny(*perms)(
102 102 user=user, repo_name=pull_request.target_repo.repo_name)
103 103 else:
104 104 return h.HasRepoPermissionAnyApi(*perms)(
105 105 user=user, repo_name=pull_request.target_repo.repo_name)
106 106
107 107 def check_user_read(self, pull_request, user, api=False):
108 108 _perms = ('repository.admin', 'repository.write', 'repository.read',)
109 109 return self._check_perms(_perms, pull_request, user, api)
110 110
111 111 def check_user_merge(self, pull_request, user, api=False):
112 112 _perms = ('repository.admin', 'repository.write', 'hg.admin',)
113 113 return self._check_perms(_perms, pull_request, user, api)
114 114
115 115 def check_user_update(self, pull_request, user, api=False):
116 116 owner = user.user_id == pull_request.user_id
117 117 return self.check_user_merge(pull_request, user, api) or owner
118 118
119 119 def check_user_change_status(self, pull_request, user, api=False):
120 120 reviewer = user.user_id in [x.user_id for x in
121 121 pull_request.reviewers]
122 122 return self.check_user_update(pull_request, user, api) or reviewer
123 123
124 124 def get(self, pull_request):
125 125 return self.__get_pull_request(pull_request)
126 126
127 127 def _prepare_get_all_query(self, repo_name, source=False, statuses=None,
128 128 opened_by=None, order_by=None,
129 129 order_dir='desc'):
130 130 repo = self._get_repo(repo_name)
131 131 q = PullRequest.query()
132 132 # source or target
133 133 if source:
134 134 q = q.filter(PullRequest.source_repo == repo)
135 135 else:
136 136 q = q.filter(PullRequest.target_repo == repo)
137 137
138 138 # closed,opened
139 139 if statuses:
140 140 q = q.filter(PullRequest.status.in_(statuses))
141 141
142 142 # opened by filter
143 143 if opened_by:
144 144 q = q.filter(PullRequest.user_id.in_(opened_by))
145 145
146 146 if order_by:
147 147 order_map = {
148 148 'name_raw': PullRequest.pull_request_id,
149 149 'title': PullRequest.title,
150 150 'updated_on_raw': PullRequest.updated_on
151 151 }
152 152 if order_dir == 'asc':
153 153 q = q.order_by(order_map[order_by].asc())
154 154 else:
155 155 q = q.order_by(order_map[order_by].desc())
156 156
157 157 return q
158 158
159 159 def count_all(self, repo_name, source=False, statuses=None,
160 160 opened_by=None):
161 161 """
162 162 Count the number of pull requests for a specific repository.
163 163
164 164 :param repo_name: target or source repo
165 165 :param source: boolean flag to specify if repo_name refers to source
166 166 :param statuses: list of pull request statuses
167 167 :param opened_by: author user of the pull request
168 168 :returns: int number of pull requests
169 169 """
170 170 q = self._prepare_get_all_query(
171 171 repo_name, source=source, statuses=statuses, opened_by=opened_by)
172 172
173 173 return q.count()
174 174
175 175 def get_all(self, repo_name, source=False, statuses=None, opened_by=None,
176 176 offset=0, length=None, order_by=None, order_dir='desc'):
177 177 """
178 178 Get all pull requests for a specific repository.
179 179
180 180 :param repo_name: target or source repo
181 181 :param source: boolean flag to specify if repo_name refers to source
182 182 :param statuses: list of pull request statuses
183 183 :param opened_by: author user of the pull request
184 184 :param offset: pagination offset
185 185 :param length: length of returned list
186 186 :param order_by: order of the returned list
187 187 :param order_dir: 'asc' or 'desc' ordering direction
188 188 :returns: list of pull requests
189 189 """
190 190 q = self._prepare_get_all_query(
191 191 repo_name, source=source, statuses=statuses, opened_by=opened_by,
192 192 order_by=order_by, order_dir=order_dir)
193 193
194 194 if length:
195 195 pull_requests = q.limit(length).offset(offset).all()
196 196 else:
197 197 pull_requests = q.all()
198 198
199 199 return pull_requests
200 200
201 201 def count_awaiting_review(self, repo_name, source=False, statuses=None,
202 202 opened_by=None):
203 203 """
204 204 Count the number of pull requests for a specific repository that are
205 205 awaiting review.
206 206
207 207 :param repo_name: target or source repo
208 208 :param source: boolean flag to specify if repo_name refers to source
209 209 :param statuses: list of pull request statuses
210 210 :param opened_by: author user of the pull request
211 211 :returns: int number of pull requests
212 212 """
213 213 pull_requests = self.get_awaiting_review(
214 214 repo_name, source=source, statuses=statuses, opened_by=opened_by)
215 215
216 216 return len(pull_requests)
217 217
218 218 def get_awaiting_review(self, repo_name, source=False, statuses=None,
219 219 opened_by=None, offset=0, length=None,
220 220 order_by=None, order_dir='desc'):
221 221 """
222 222 Get all pull requests for a specific repository that are awaiting
223 223 review.
224 224
225 225 :param repo_name: target or source repo
226 226 :param source: boolean flag to specify if repo_name refers to source
227 227 :param statuses: list of pull request statuses
228 228 :param opened_by: author user of the pull request
229 229 :param offset: pagination offset
230 230 :param length: length of returned list
231 231 :param order_by: order of the returned list
232 232 :param order_dir: 'asc' or 'desc' ordering direction
233 233 :returns: list of pull requests
234 234 """
235 235 pull_requests = self.get_all(
236 236 repo_name, source=source, statuses=statuses, opened_by=opened_by,
237 237 order_by=order_by, order_dir=order_dir)
238 238
239 239 _filtered_pull_requests = []
240 240 for pr in pull_requests:
241 241 status = pr.calculated_review_status()
242 242 if status in [ChangesetStatus.STATUS_NOT_REVIEWED,
243 243 ChangesetStatus.STATUS_UNDER_REVIEW]:
244 244 _filtered_pull_requests.append(pr)
245 245 if length:
246 246 return _filtered_pull_requests[offset:offset+length]
247 247 else:
248 248 return _filtered_pull_requests
249 249
250 250 def count_awaiting_my_review(self, repo_name, source=False, statuses=None,
251 251 opened_by=None, user_id=None):
252 252 """
253 253 Count the number of pull requests for a specific repository that are
254 254 awaiting review from a specific user.
255 255
256 256 :param repo_name: target or source repo
257 257 :param source: boolean flag to specify if repo_name refers to source
258 258 :param statuses: list of pull request statuses
259 259 :param opened_by: author user of the pull request
260 260 :param user_id: reviewer user of the pull request
261 261 :returns: int number of pull requests
262 262 """
263 263 pull_requests = self.get_awaiting_my_review(
264 264 repo_name, source=source, statuses=statuses, opened_by=opened_by,
265 265 user_id=user_id)
266 266
267 267 return len(pull_requests)
268 268
269 269 def get_awaiting_my_review(self, repo_name, source=False, statuses=None,
270 270 opened_by=None, user_id=None, offset=0,
271 271 length=None, order_by=None, order_dir='desc'):
272 272 """
273 273 Get all pull requests for a specific repository that are awaiting
274 274 review from a specific user.
275 275
276 276 :param repo_name: target or source repo
277 277 :param source: boolean flag to specify if repo_name refers to source
278 278 :param statuses: list of pull request statuses
279 279 :param opened_by: author user of the pull request
280 280 :param user_id: reviewer user of the pull request
281 281 :param offset: pagination offset
282 282 :param length: length of returned list
283 283 :param order_by: order of the returned list
284 284 :param order_dir: 'asc' or 'desc' ordering direction
285 285 :returns: list of pull requests
286 286 """
287 287 pull_requests = self.get_all(
288 288 repo_name, source=source, statuses=statuses, opened_by=opened_by,
289 289 order_by=order_by, order_dir=order_dir)
290 290
291 291 _my = PullRequestModel().get_not_reviewed(user_id)
292 292 my_participation = []
293 293 for pr in pull_requests:
294 294 if pr in _my:
295 295 my_participation.append(pr)
296 296 _filtered_pull_requests = my_participation
297 297 if length:
298 298 return _filtered_pull_requests[offset:offset+length]
299 299 else:
300 300 return _filtered_pull_requests
301 301
302 302 def get_not_reviewed(self, user_id):
303 303 return [
304 304 x.pull_request for x in PullRequestReviewers.query().filter(
305 305 PullRequestReviewers.user_id == user_id).all()
306 306 ]
307 307
308 308 def get_versions(self, pull_request):
309 309 """
310 310 returns version of pull request sorted by ID descending
311 311 """
312 312 return PullRequestVersion.query()\
313 313 .filter(PullRequestVersion.pull_request == pull_request)\
314 314 .order_by(PullRequestVersion.pull_request_version_id.asc())\
315 315 .all()
316 316
317 317 def create(self, created_by, source_repo, source_ref, target_repo,
318 318 target_ref, revisions, reviewers, title, description=None):
319 319 created_by_user = self._get_user(created_by)
320 320 source_repo = self._get_repo(source_repo)
321 321 target_repo = self._get_repo(target_repo)
322 322
323 323 pull_request = PullRequest()
324 324 pull_request.source_repo = source_repo
325 325 pull_request.source_ref = source_ref
326 326 pull_request.target_repo = target_repo
327 327 pull_request.target_ref = target_ref
328 328 pull_request.revisions = revisions
329 329 pull_request.title = title
330 330 pull_request.description = description
331 331 pull_request.author = created_by_user
332 332
333 333 Session().add(pull_request)
334 334 Session().flush()
335 335
336 336 # members / reviewers
337 337 for user_id in set(reviewers):
338 338 user = self._get_user(user_id)
339 339 reviewer = PullRequestReviewers(user, pull_request)
340 340 Session().add(reviewer)
341 341
342 342 # Set approval status to "Under Review" for all commits which are
343 343 # part of this pull request.
344 344 ChangesetStatusModel().set_status(
345 345 repo=target_repo,
346 346 status=ChangesetStatus.STATUS_UNDER_REVIEW,
347 347 user=created_by_user,
348 348 pull_request=pull_request
349 349 )
350 350
351 351 self.notify_reviewers(pull_request, reviewers)
352 352 self._trigger_pull_request_hook(
353 353 pull_request, created_by_user, 'create')
354 354
355 355 return pull_request
356 356
357 357 def _trigger_pull_request_hook(self, pull_request, user, action):
358 358 pull_request = self.__get_pull_request(pull_request)
359 359 target_scm = pull_request.target_repo.scm_instance()
360 360 if action == 'create':
361 361 trigger_hook = hooks_utils.trigger_log_create_pull_request_hook
362 362 elif action == 'merge':
363 363 trigger_hook = hooks_utils.trigger_log_merge_pull_request_hook
364 364 elif action == 'close':
365 365 trigger_hook = hooks_utils.trigger_log_close_pull_request_hook
366 366 elif action == 'review_status_change':
367 367 trigger_hook = hooks_utils.trigger_log_review_pull_request_hook
368 368 elif action == 'update':
369 369 trigger_hook = hooks_utils.trigger_log_update_pull_request_hook
370 370 else:
371 371 return
372 372
373 373 trigger_hook(
374 374 username=user.username,
375 375 repo_name=pull_request.target_repo.repo_name,
376 376 repo_alias=target_scm.alias,
377 377 pull_request=pull_request)
378 378
379 379 def _get_commit_ids(self, pull_request):
380 380 """
381 381 Return the commit ids of the merged pull request.
382 382
383 383 This method is not dealing correctly yet with the lack of autoupdates
384 384 nor with the implicit target updates.
385 385 For example: if a commit in the source repo is already in the target it
386 386 will be reported anyways.
387 387 """
388 388 merge_rev = pull_request.merge_rev
389 389 if merge_rev is None:
390 390 raise ValueError('This pull request was not merged yet')
391 391
392 392 commit_ids = list(pull_request.revisions)
393 393 if merge_rev not in commit_ids:
394 394 commit_ids.append(merge_rev)
395 395
396 396 return commit_ids
397 397
398 398 def merge(self, pull_request, user, extras):
399 399 log.debug("Merging pull request %s", pull_request.pull_request_id)
400 400 merge_state = self._merge_pull_request(pull_request, user, extras)
401 401 if merge_state.executed:
402 402 log.debug(
403 403 "Merge was successful, updating the pull request comments.")
404 404 self._comment_and_close_pr(pull_request, user, merge_state)
405 405 self._log_action('user_merged_pull_request', user, pull_request)
406 406 else:
407 407 log.warn("Merge failed, not updating the pull request.")
408 408 return merge_state
409 409
410 410 def _merge_pull_request(self, pull_request, user, extras):
411 411 target_vcs = pull_request.target_repo.scm_instance()
412 412 source_vcs = pull_request.source_repo.scm_instance()
413 413 target_ref = self._refresh_reference(
414 414 pull_request.target_ref_parts, target_vcs)
415 415
416 416 message = _(
417 417 'Merge pull request #%(pr_id)s from '
418 418 '%(source_repo)s %(source_ref_name)s\n\n %(pr_title)s') % {
419 419 'pr_id': pull_request.pull_request_id,
420 420 'source_repo': source_vcs.name,
421 421 'source_ref_name': pull_request.source_ref_parts.name,
422 422 'pr_title': pull_request.title
423 423 }
424 424
425 425 workspace_id = self._workspace_id(pull_request)
426 426 protocol = rhodecode.CONFIG.get('vcs.hooks.protocol')
427 427 use_direct_calls = rhodecode.CONFIG.get('vcs.hooks.direct_calls')
428 428 use_rebase = self._use_rebase_for_merging(pull_request)
429 429
430 430 callback_daemon, extras = prepare_callback_daemon(
431 431 extras, protocol=protocol, use_direct_calls=use_direct_calls)
432 432
433 433 with callback_daemon:
434 434 # TODO: johbo: Implement a clean way to run a config_override
435 435 # for a single call.
436 436 target_vcs.config.set(
437 437 'rhodecode', 'RC_SCM_DATA', json.dumps(extras))
438 438 merge_state = target_vcs.merge(
439 439 target_ref, source_vcs, pull_request.source_ref_parts,
440 440 workspace_id, user_name=user.username,
441 441 user_email=user.email, message=message, use_rebase=use_rebase)
442 442 return merge_state
443 443
444 444 def _comment_and_close_pr(self, pull_request, user, merge_state):
445 445 pull_request.merge_rev = merge_state.merge_commit_id
446 446 pull_request.updated_on = datetime.datetime.now()
447 447
448 448 ChangesetCommentsModel().create(
449 449 text=unicode(_('Pull request merged and closed')),
450 450 repo=pull_request.target_repo.repo_id,
451 451 user=user.user_id,
452 452 pull_request=pull_request.pull_request_id,
453 453 f_path=None,
454 454 line_no=None,
455 455 closing_pr=True
456 456 )
457 457
458 458 Session().add(pull_request)
459 459 Session().flush()
460 460 # TODO: paris: replace invalidation with less radical solution
461 461 ScmModel().mark_for_invalidation(
462 462 pull_request.target_repo.repo_name)
463 463 self._trigger_pull_request_hook(pull_request, user, 'merge')
464 464
465 465 def has_valid_update_type(self, pull_request):
466 466 source_ref_type = pull_request.source_ref_parts.type
467 467 return source_ref_type in ['book', 'branch', 'tag']
468 468
469 469 def update_commits(self, pull_request):
470 470 """
471 471 Get the updated list of commits for the pull request
472 472 and return the new pull request version and the list
473 473 of commits processed by this update action
474 474 """
475 475
476 476 pull_request = self.__get_pull_request(pull_request)
477 477 source_ref_type = pull_request.source_ref_parts.type
478 478 source_ref_name = pull_request.source_ref_parts.name
479 479 source_ref_id = pull_request.source_ref_parts.commit_id
480 480
481 481 if not self.has_valid_update_type(pull_request):
482 482 log.debug(
483 483 "Skipping update of pull request %s due to ref type: %s",
484 484 pull_request, source_ref_type)
485 485 return (None, None)
486 486
487 487 source_repo = pull_request.source_repo.scm_instance()
488 488 source_commit = source_repo.get_commit(commit_id=source_ref_name)
489 489 if source_ref_id == source_commit.raw_id:
490 490 log.debug("Nothing changed in pull request %s", pull_request)
491 491 return (None, None)
492 492
493 493 # Finally there is a need for an update
494 494 pull_request_version = self._create_version_from_snapshot(pull_request)
495 495 self._link_comments_to_version(pull_request_version)
496 496
497 497 target_ref_type = pull_request.target_ref_parts.type
498 498 target_ref_name = pull_request.target_ref_parts.name
499 499 target_ref_id = pull_request.target_ref_parts.commit_id
500 500 target_repo = pull_request.target_repo.scm_instance()
501 501
502 502 if target_ref_type in ('tag', 'branch', 'book'):
503 503 target_commit = target_repo.get_commit(target_ref_name)
504 504 else:
505 505 target_commit = target_repo.get_commit(target_ref_id)
506 506
507 507 # re-compute commit ids
508 508 old_commit_ids = set(pull_request.revisions)
509 509 pre_load = ["author", "branch", "date", "message"]
510 510 commit_ranges = target_repo.compare(
511 511 target_commit.raw_id, source_commit.raw_id, source_repo, merge=True,
512 512 pre_load=pre_load)
513 513
514 514 ancestor = target_repo.get_common_ancestor(
515 515 target_commit.raw_id, source_commit.raw_id, source_repo)
516 516
517 517 pull_request.source_ref = '%s:%s:%s' % (
518 518 source_ref_type, source_ref_name, source_commit.raw_id)
519 519 pull_request.target_ref = '%s:%s:%s' % (
520 520 target_ref_type, target_ref_name, ancestor)
521 521 pull_request.revisions = [
522 522 commit.raw_id for commit in reversed(commit_ranges)]
523 523 pull_request.updated_on = datetime.datetime.now()
524 524 Session().add(pull_request)
525 525 new_commit_ids = set(pull_request.revisions)
526 526
527 527 changes = self._calculate_commit_id_changes(
528 528 old_commit_ids, new_commit_ids)
529 529
530 530 old_diff_data, new_diff_data = self._generate_update_diffs(
531 531 pull_request, pull_request_version)
532 532
533 533 ChangesetCommentsModel().outdate_comments(
534 534 pull_request, old_diff_data=old_diff_data,
535 535 new_diff_data=new_diff_data)
536 536
537 537 file_changes = self._calculate_file_changes(
538 538 old_diff_data, new_diff_data)
539 539
540 540 # Add an automatic comment to the pull request
541 541 update_comment = ChangesetCommentsModel().create(
542 542 text=self._render_update_message(changes, file_changes),
543 543 repo=pull_request.target_repo,
544 544 user=pull_request.author,
545 545 pull_request=pull_request,
546 546 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER)
547 547
548 548 # Update status to "Under Review" for added commits
549 549 for commit_id in changes.added:
550 550 ChangesetStatusModel().set_status(
551 551 repo=pull_request.source_repo,
552 552 status=ChangesetStatus.STATUS_UNDER_REVIEW,
553 553 comment=update_comment,
554 554 user=pull_request.author,
555 555 pull_request=pull_request,
556 556 revision=commit_id)
557 557
558 558 log.debug(
559 559 'Updated pull request %s, added_ids: %s, common_ids: %s, '
560 560 'removed_ids: %s', pull_request.pull_request_id,
561 561 changes.added, changes.common, changes.removed)
562 562 log.debug('Updated pull request with the following file changes: %s',
563 563 file_changes)
564 564
565 565 log.info(
566 566 "Updated pull request %s from commit %s to commit %s, "
567 567 "stored new version %s of this pull request.",
568 568 pull_request.pull_request_id, source_ref_id,
569 569 pull_request.source_ref_parts.commit_id,
570 570 pull_request_version.pull_request_version_id)
571 571 Session().commit()
572 572 self._trigger_pull_request_hook(pull_request, pull_request.author,
573 573 'update')
574 574 return (pull_request_version, changes)
575 575
576 576 def _create_version_from_snapshot(self, pull_request):
577 577 version = PullRequestVersion()
578 578 version.title = pull_request.title
579 579 version.description = pull_request.description
580 580 version.status = pull_request.status
581 581 version.created_on = pull_request.created_on
582 582 version.updated_on = pull_request.updated_on
583 583 version.user_id = pull_request.user_id
584 584 version.source_repo = pull_request.source_repo
585 585 version.source_ref = pull_request.source_ref
586 586 version.target_repo = pull_request.target_repo
587 587 version.target_ref = pull_request.target_ref
588 588
589 589 version._last_merge_source_rev = pull_request._last_merge_source_rev
590 590 version._last_merge_target_rev = pull_request._last_merge_target_rev
591 591 version._last_merge_status = pull_request._last_merge_status
592 592 version.merge_rev = pull_request.merge_rev
593 593
594 594 version.revisions = pull_request.revisions
595 595 version.pull_request = pull_request
596 596 Session().add(version)
597 597 Session().flush()
598 598
599 599 return version
600 600
601 601 def _generate_update_diffs(self, pull_request, pull_request_version):
602 602 diff_context = (
603 603 self.DIFF_CONTEXT +
604 604 ChangesetCommentsModel.needed_extra_diff_context())
605 605 old_diff = self._get_diff_from_pr_or_version(
606 606 pull_request_version, context=diff_context)
607 607 new_diff = self._get_diff_from_pr_or_version(
608 608 pull_request, context=diff_context)
609 609
610 610 old_diff_data = diffs.DiffProcessor(old_diff)
611 611 old_diff_data.prepare()
612 612 new_diff_data = diffs.DiffProcessor(new_diff)
613 613 new_diff_data.prepare()
614 614
615 615 return old_diff_data, new_diff_data
616 616
617 617 def _link_comments_to_version(self, pull_request_version):
618 618 """
619 619 Link all unlinked comments of this pull request to the given version.
620 620
621 621 :param pull_request_version: The `PullRequestVersion` to which
622 622 the comments shall be linked.
623 623
624 624 """
625 625 pull_request = pull_request_version.pull_request
626 626 comments = ChangesetComment.query().filter(
627 627 # TODO: johbo: Should we query for the repo at all here?
628 628 # Pending decision on how comments of PRs are to be related
629 629 # to either the source repo, the target repo or no repo at all.
630 630 ChangesetComment.repo_id == pull_request.target_repo.repo_id,
631 631 ChangesetComment.pull_request == pull_request,
632 632 ChangesetComment.pull_request_version == None)
633 633
634 634 # TODO: johbo: Find out why this breaks if it is done in a bulk
635 635 # operation.
636 636 for comment in comments:
637 637 comment.pull_request_version_id = (
638 638 pull_request_version.pull_request_version_id)
639 639 Session().add(comment)
640 640
641 641 def _calculate_commit_id_changes(self, old_ids, new_ids):
642 642 added = new_ids.difference(old_ids)
643 643 common = old_ids.intersection(new_ids)
644 644 removed = old_ids.difference(new_ids)
645 645 return ChangeTuple(added, common, removed)
646 646
647 647 def _calculate_file_changes(self, old_diff_data, new_diff_data):
648 648
649 649 old_files = OrderedDict()
650 650 for diff_data in old_diff_data.parsed_diff:
651 651 old_files[diff_data['filename']] = md5_safe(diff_data['raw_diff'])
652 652
653 653 added_files = []
654 654 modified_files = []
655 655 removed_files = []
656 656 for diff_data in new_diff_data.parsed_diff:
657 657 new_filename = diff_data['filename']
658 658 new_hash = md5_safe(diff_data['raw_diff'])
659 659
660 660 old_hash = old_files.get(new_filename)
661 661 if not old_hash:
662 662 # file is not present in old diff, means it's added
663 663 added_files.append(new_filename)
664 664 else:
665 665 if new_hash != old_hash:
666 666 modified_files.append(new_filename)
667 667 # now remove a file from old, since we have seen it already
668 668 del old_files[new_filename]
669 669
670 670 # removed files is when there are present in old, but not in NEW,
671 671 # since we remove old files that are present in new diff, left-overs
672 672 # if any should be the removed files
673 673 removed_files.extend(old_files.keys())
674 674
675 675 return FileChangeTuple(added_files, modified_files, removed_files)
676 676
677 677 def _render_update_message(self, changes, file_changes):
678 678 """
679 679 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
680 680 so it's always looking the same disregarding on which default
681 681 renderer system is using.
682 682
683 683 :param changes: changes named tuple
684 684 :param file_changes: file changes named tuple
685 685
686 686 """
687 687 new_status = ChangesetStatus.get_status_lbl(
688 688 ChangesetStatus.STATUS_UNDER_REVIEW)
689 689
690 690 changed_files = (
691 691 file_changes.added + file_changes.modified + file_changes.removed)
692 692
693 693 params = {
694 694 'under_review_label': new_status,
695 695 'added_commits': changes.added,
696 696 'removed_commits': changes.removed,
697 697 'changed_files': changed_files,
698 698 'added_files': file_changes.added,
699 699 'modified_files': file_changes.modified,
700 700 'removed_files': file_changes.removed,
701 701 }
702 702 renderer = RstTemplateRenderer()
703 703 return renderer.render('pull_request_update.mako', **params)
704 704
705 705 def edit(self, pull_request, title, description):
706 706 pull_request = self.__get_pull_request(pull_request)
707 707 if pull_request.is_closed():
708 708 raise ValueError('This pull request is closed')
709 709 if title:
710 710 pull_request.title = title
711 711 pull_request.description = description
712 712 pull_request.updated_on = datetime.datetime.now()
713 713 Session().add(pull_request)
714 714
715 715 def update_reviewers(self, pull_request, reviewers_ids):
716 716 reviewers_ids = set(reviewers_ids)
717 717 pull_request = self.__get_pull_request(pull_request)
718 718 current_reviewers = PullRequestReviewers.query()\
719 719 .filter(PullRequestReviewers.pull_request ==
720 720 pull_request).all()
721 721 current_reviewers_ids = set([x.user.user_id for x in current_reviewers])
722 722
723 723 ids_to_add = reviewers_ids.difference(current_reviewers_ids)
724 724 ids_to_remove = current_reviewers_ids.difference(reviewers_ids)
725 725
726 726 log.debug("Adding %s reviewers", ids_to_add)
727 727 log.debug("Removing %s reviewers", ids_to_remove)
728 728 changed = False
729 729 for uid in ids_to_add:
730 730 changed = True
731 731 _usr = self._get_user(uid)
732 732 reviewer = PullRequestReviewers(_usr, pull_request)
733 733 Session().add(reviewer)
734 734
735 735 self.notify_reviewers(pull_request, ids_to_add)
736 736
737 737 for uid in ids_to_remove:
738 738 changed = True
739 739 reviewer = PullRequestReviewers.query()\
740 740 .filter(PullRequestReviewers.user_id == uid,
741 741 PullRequestReviewers.pull_request == pull_request)\
742 742 .scalar()
743 743 if reviewer:
744 744 Session().delete(reviewer)
745 745 if changed:
746 746 pull_request.updated_on = datetime.datetime.now()
747 747 Session().add(pull_request)
748 748
749 749 return ids_to_add, ids_to_remove
750 750
751 751 def get_url(self, pull_request):
752 752 return h.url('pullrequest_show',
753 753 repo_name=safe_str(pull_request.target_repo.repo_name),
754 754 pull_request_id=pull_request.pull_request_id,
755 755 qualified=True)
756 756
757 757 def notify_reviewers(self, pull_request, reviewers_ids):
758 758 # notification to reviewers
759 759 if not reviewers_ids:
760 760 return
761 761
762 762 pull_request_obj = pull_request
763 763 # get the current participants of this pull request
764 764 recipients = reviewers_ids
765 765 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST
766 766
767 767 pr_source_repo = pull_request_obj.source_repo
768 768 pr_target_repo = pull_request_obj.target_repo
769 769
770 770 pr_url = h.url(
771 771 'pullrequest_show',
772 772 repo_name=pr_target_repo.repo_name,
773 773 pull_request_id=pull_request_obj.pull_request_id,
774 774 qualified=True,)
775 775
776 776 # set some variables for email notification
777 777 pr_target_repo_url = h.url(
778 778 'summary_home',
779 779 repo_name=pr_target_repo.repo_name,
780 780 qualified=True)
781 781
782 782 pr_source_repo_url = h.url(
783 783 'summary_home',
784 784 repo_name=pr_source_repo.repo_name,
785 785 qualified=True)
786 786
787 787 # pull request specifics
788 788 pull_request_commits = [
789 789 (x.raw_id, x.message)
790 790 for x in map(pr_source_repo.get_commit, pull_request.revisions)]
791 791
792 792 kwargs = {
793 793 'user': pull_request.author,
794 794 'pull_request': pull_request_obj,
795 795 'pull_request_commits': pull_request_commits,
796 796
797 797 'pull_request_target_repo': pr_target_repo,
798 798 'pull_request_target_repo_url': pr_target_repo_url,
799 799
800 800 'pull_request_source_repo': pr_source_repo,
801 801 'pull_request_source_repo_url': pr_source_repo_url,
802 802
803 803 'pull_request_url': pr_url,
804 804 }
805 805
806 806 # pre-generate the subject for notification itself
807 807 (subject,
808 808 _h, _e, # we don't care about those
809 809 body_plaintext) = EmailNotificationModel().render_email(
810 810 notification_type, **kwargs)
811 811
812 812 # create notification objects, and emails
813 813 NotificationModel().create(
814 814 created_by=pull_request.author,
815 815 notification_subject=subject,
816 816 notification_body=body_plaintext,
817 817 notification_type=notification_type,
818 818 recipients=recipients,
819 819 email_kwargs=kwargs,
820 820 )
821 821
822 822 def delete(self, pull_request):
823 823 pull_request = self.__get_pull_request(pull_request)
824 824 self._cleanup_merge_workspace(pull_request)
825 825 Session().delete(pull_request)
826 826
827 827 def close_pull_request(self, pull_request, user):
828 828 pull_request = self.__get_pull_request(pull_request)
829 829 self._cleanup_merge_workspace(pull_request)
830 830 pull_request.status = PullRequest.STATUS_CLOSED
831 831 pull_request.updated_on = datetime.datetime.now()
832 832 Session().add(pull_request)
833 833 self._trigger_pull_request_hook(
834 834 pull_request, pull_request.author, 'close')
835 835 self._log_action('user_closed_pull_request', user, pull_request)
836 836
837 837 def close_pull_request_with_comment(self, pull_request, user, repo,
838 838 message=None):
839 839 status = ChangesetStatus.STATUS_REJECTED
840 840
841 841 if not message:
842 842 message = (
843 843 _('Status change %(transition_icon)s %(status)s') % {
844 844 'transition_icon': '>',
845 845 'status': ChangesetStatus.get_status_lbl(status)})
846 846
847 847 internal_message = _('Closing with') + ' ' + message
848 848
849 849 comm = ChangesetCommentsModel().create(
850 850 text=internal_message,
851 851 repo=repo.repo_id,
852 852 user=user.user_id,
853 853 pull_request=pull_request.pull_request_id,
854 854 f_path=None,
855 855 line_no=None,
856 856 status_change=ChangesetStatus.get_status_lbl(status),
857 status_change_type=status,
857 858 closing_pr=True
858 859 )
859 860
860 861 ChangesetStatusModel().set_status(
861 862 repo.repo_id,
862 863 status,
863 864 user.user_id,
864 865 comm,
865 866 pull_request=pull_request.pull_request_id
866 867 )
867 868 Session().flush()
868 869
869 870 PullRequestModel().close_pull_request(
870 871 pull_request.pull_request_id, user)
871 872
872 873 def merge_status(self, pull_request):
873 874 if not self._is_merge_enabled(pull_request):
874 875 return False, _('Server-side pull request merging is disabled.')
875 876 if pull_request.is_closed():
876 877 return False, _('This pull request is closed.')
877 878 merge_possible, msg = self._check_repo_requirements(
878 879 target=pull_request.target_repo, source=pull_request.source_repo)
879 880 if not merge_possible:
880 881 return merge_possible, msg
881 882
882 883 try:
883 884 resp = self._try_merge(pull_request)
884 885 status = resp.possible, self.merge_status_message(
885 886 resp.failure_reason)
886 887 except NotImplementedError:
887 888 status = False, _('Pull request merging is not supported.')
888 889
889 890 return status
890 891
891 892 def _check_repo_requirements(self, target, source):
892 893 """
893 894 Check if `target` and `source` have compatible requirements.
894 895
895 896 Currently this is just checking for largefiles.
896 897 """
897 898 target_has_largefiles = self._has_largefiles(target)
898 899 source_has_largefiles = self._has_largefiles(source)
899 900 merge_possible = True
900 901 message = u''
901 902
902 903 if target_has_largefiles != source_has_largefiles:
903 904 merge_possible = False
904 905 if source_has_largefiles:
905 906 message = _(
906 907 'Target repository large files support is disabled.')
907 908 else:
908 909 message = _(
909 910 'Source repository large files support is disabled.')
910 911
911 912 return merge_possible, message
912 913
913 914 def _has_largefiles(self, repo):
914 915 largefiles_ui = VcsSettingsModel(repo=repo).get_ui_settings(
915 916 'extensions', 'largefiles')
916 917 return largefiles_ui and largefiles_ui[0].active
917 918
918 919 def _try_merge(self, pull_request):
919 920 """
920 921 Try to merge the pull request and return the merge status.
921 922 """
922 923 log.debug(
923 924 "Trying out if the pull request %s can be merged.",
924 925 pull_request.pull_request_id)
925 926 target_vcs = pull_request.target_repo.scm_instance()
926 927 target_ref = self._refresh_reference(
927 928 pull_request.target_ref_parts, target_vcs)
928 929
929 930 target_locked = pull_request.target_repo.locked
930 931 if target_locked and target_locked[0]:
931 932 log.debug("The target repository is locked.")
932 933 merge_state = MergeResponse(
933 934 False, False, None, MergeFailureReason.TARGET_IS_LOCKED)
934 935 elif self._needs_merge_state_refresh(pull_request, target_ref):
935 936 log.debug("Refreshing the merge status of the repository.")
936 937 merge_state = self._refresh_merge_state(
937 938 pull_request, target_vcs, target_ref)
938 939 else:
939 940 possible = pull_request.\
940 941 _last_merge_status == MergeFailureReason.NONE
941 942 merge_state = MergeResponse(
942 943 possible, False, None, pull_request._last_merge_status)
943 944 log.debug("Merge response: %s", merge_state)
944 945 return merge_state
945 946
946 947 def _refresh_reference(self, reference, vcs_repository):
947 948 if reference.type in ('branch', 'book'):
948 949 name_or_id = reference.name
949 950 else:
950 951 name_or_id = reference.commit_id
951 952 refreshed_commit = vcs_repository.get_commit(name_or_id)
952 953 refreshed_reference = Reference(
953 954 reference.type, reference.name, refreshed_commit.raw_id)
954 955 return refreshed_reference
955 956
956 957 def _needs_merge_state_refresh(self, pull_request, target_reference):
957 958 return not(
958 959 pull_request.revisions and
959 960 pull_request.revisions[0] == pull_request._last_merge_source_rev and
960 961 target_reference.commit_id == pull_request._last_merge_target_rev)
961 962
962 963 def _refresh_merge_state(self, pull_request, target_vcs, target_reference):
963 964 workspace_id = self._workspace_id(pull_request)
964 965 source_vcs = pull_request.source_repo.scm_instance()
965 966 use_rebase = self._use_rebase_for_merging(pull_request)
966 967 merge_state = target_vcs.merge(
967 968 target_reference, source_vcs, pull_request.source_ref_parts,
968 969 workspace_id, dry_run=True, use_rebase=use_rebase)
969 970
970 971 # Do not store the response if there was an unknown error.
971 972 if merge_state.failure_reason != MergeFailureReason.UNKNOWN:
972 973 pull_request._last_merge_source_rev = pull_request.\
973 974 source_ref_parts.commit_id
974 975 pull_request._last_merge_target_rev = target_reference.commit_id
975 976 pull_request._last_merge_status = (
976 977 merge_state.failure_reason)
977 978 Session().add(pull_request)
978 979 Session().flush()
979 980
980 981 return merge_state
981 982
982 983 def _workspace_id(self, pull_request):
983 984 workspace_id = 'pr-%s' % pull_request.pull_request_id
984 985 return workspace_id
985 986
986 987 def merge_status_message(self, status_code):
987 988 """
988 989 Return a human friendly error message for the given merge status code.
989 990 """
990 991 return self.MERGE_STATUS_MESSAGES[status_code]
991 992
992 993 def generate_repo_data(self, repo, commit_id=None, branch=None,
993 994 bookmark=None):
994 995 all_refs, selected_ref = \
995 996 self._get_repo_pullrequest_sources(
996 997 repo.scm_instance(), commit_id=commit_id,
997 998 branch=branch, bookmark=bookmark)
998 999
999 1000 refs_select2 = []
1000 1001 for element in all_refs:
1001 1002 children = [{'id': x[0], 'text': x[1]} for x in element[0]]
1002 1003 refs_select2.append({'text': element[1], 'children': children})
1003 1004
1004 1005 return {
1005 1006 'user': {
1006 1007 'user_id': repo.user.user_id,
1007 1008 'username': repo.user.username,
1008 1009 'firstname': repo.user.firstname,
1009 1010 'lastname': repo.user.lastname,
1010 1011 'gravatar_link': h.gravatar_url(repo.user.email, 14),
1011 1012 },
1012 1013 'description': h.chop_at_smart(repo.description, '\n'),
1013 1014 'refs': {
1014 1015 'all_refs': all_refs,
1015 1016 'selected_ref': selected_ref,
1016 1017 'select2_refs': refs_select2
1017 1018 }
1018 1019 }
1019 1020
1020 1021 def generate_pullrequest_title(self, source, source_ref, target):
1021 1022 return '{source}#{at_ref} to {target}'.format(
1022 1023 source=source,
1023 1024 at_ref=source_ref,
1024 1025 target=target,
1025 1026 )
1026 1027
1027 1028 def _cleanup_merge_workspace(self, pull_request):
1028 1029 # Merging related cleanup
1029 1030 target_scm = pull_request.target_repo.scm_instance()
1030 1031 workspace_id = 'pr-%s' % pull_request.pull_request_id
1031 1032
1032 1033 try:
1033 1034 target_scm.cleanup_merge_workspace(workspace_id)
1034 1035 except NotImplementedError:
1035 1036 pass
1036 1037
1037 1038 def _get_repo_pullrequest_sources(
1038 1039 self, repo, commit_id=None, branch=None, bookmark=None):
1039 1040 """
1040 1041 Return a structure with repo's interesting commits, suitable for
1041 1042 the selectors in pullrequest controller
1042 1043
1043 1044 :param commit_id: a commit that must be in the list somehow
1044 1045 and selected by default
1045 1046 :param branch: a branch that must be in the list and selected
1046 1047 by default - even if closed
1047 1048 :param bookmark: a bookmark that must be in the list and selected
1048 1049 """
1049 1050
1050 1051 commit_id = safe_str(commit_id) if commit_id else None
1051 1052 branch = safe_str(branch) if branch else None
1052 1053 bookmark = safe_str(bookmark) if bookmark else None
1053 1054
1054 1055 selected = None
1055 1056
1056 1057 # order matters: first source that has commit_id in it will be selected
1057 1058 sources = []
1058 1059 sources.append(('book', repo.bookmarks.items(), _('Bookmarks'), bookmark))
1059 1060 sources.append(('branch', repo.branches.items(), _('Branches'), branch))
1060 1061
1061 1062 if commit_id:
1062 1063 ref_commit = (h.short_id(commit_id), commit_id)
1063 1064 sources.append(('rev', [ref_commit], _('Commit IDs'), commit_id))
1064 1065
1065 1066 sources.append(
1066 1067 ('branch', repo.branches_closed.items(), _('Closed Branches'), branch),
1067 1068 )
1068 1069
1069 1070 groups = []
1070 1071 for group_key, ref_list, group_name, match in sources:
1071 1072 group_refs = []
1072 1073 for ref_name, ref_id in ref_list:
1073 1074 ref_key = '%s:%s:%s' % (group_key, ref_name, ref_id)
1074 1075 group_refs.append((ref_key, ref_name))
1075 1076
1076 1077 if not selected:
1077 1078 if set([commit_id, match]) & set([ref_id, ref_name]):
1078 1079 selected = ref_key
1079 1080
1080 1081 if group_refs:
1081 1082 groups.append((group_refs, group_name))
1082 1083
1083 1084 if not selected:
1084 1085 ref = commit_id or branch or bookmark
1085 1086 if ref:
1086 1087 raise CommitDoesNotExistError(
1087 1088 'No commit refs could be found matching: %s' % ref)
1088 1089 elif repo.DEFAULT_BRANCH_NAME in repo.branches:
1089 1090 selected = 'branch:%s:%s' % (
1090 1091 repo.DEFAULT_BRANCH_NAME,
1091 1092 repo.branches[repo.DEFAULT_BRANCH_NAME]
1092 1093 )
1093 1094 elif repo.commit_ids:
1094 1095 rev = repo.commit_ids[0]
1095 1096 selected = 'rev:%s:%s' % (rev, rev)
1096 1097 else:
1097 1098 raise EmptyRepositoryError()
1098 1099 return groups, selected
1099 1100
1100 1101 def get_diff(self, pull_request, context=DIFF_CONTEXT):
1101 1102 pull_request = self.__get_pull_request(pull_request)
1102 1103 return self._get_diff_from_pr_or_version(pull_request, context=context)
1103 1104
1104 1105 def _get_diff_from_pr_or_version(self, pr_or_version, context):
1105 1106 source_repo = pr_or_version.source_repo
1106 1107
1107 1108 # we swap org/other ref since we run a simple diff on one repo
1108 1109 target_ref_id = pr_or_version.target_ref_parts.commit_id
1109 1110 source_ref_id = pr_or_version.source_ref_parts.commit_id
1110 1111 target_commit = source_repo.get_commit(
1111 1112 commit_id=safe_str(target_ref_id))
1112 1113 source_commit = source_repo.get_commit(commit_id=safe_str(source_ref_id))
1113 1114 vcs_repo = source_repo.scm_instance()
1114 1115
1115 1116 # TODO: johbo: In the context of an update, we cannot reach
1116 1117 # the old commit anymore with our normal mechanisms. It needs
1117 1118 # some sort of special support in the vcs layer to avoid this
1118 1119 # workaround.
1119 1120 if (source_commit.raw_id == vcs_repo.EMPTY_COMMIT_ID and
1120 1121 vcs_repo.alias == 'git'):
1121 1122 source_commit.raw_id = safe_str(source_ref_id)
1122 1123
1123 1124 log.debug('calculating diff between '
1124 1125 'source_ref:%s and target_ref:%s for repo `%s`',
1125 1126 target_ref_id, source_ref_id,
1126 1127 safe_unicode(vcs_repo.path))
1127 1128
1128 1129 vcs_diff = vcs_repo.get_diff(
1129 1130 commit1=target_commit, commit2=source_commit, context=context)
1130 1131 return vcs_diff
1131 1132
1132 1133 def _is_merge_enabled(self, pull_request):
1133 1134 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1134 1135 settings = settings_model.get_general_settings()
1135 1136 return settings.get('rhodecode_pr_merge_enabled', False)
1136 1137
1137 1138 def _use_rebase_for_merging(self, pull_request):
1138 1139 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
1139 1140 settings = settings_model.get_general_settings()
1140 1141 return settings.get('rhodecode_hg_use_rebase_for_merging', False)
1141 1142
1142 1143 def _log_action(self, action, user, pull_request):
1143 1144 action_logger(
1144 1145 user,
1145 1146 '{action}:{pr_id}'.format(
1146 1147 action=action, pr_id=pull_request.pull_request_id),
1147 1148 pull_request.target_repo)
1148 1149
1149 1150
1150 1151 ChangeTuple = namedtuple('ChangeTuple',
1151 1152 ['added', 'common', 'removed'])
1152 1153
1153 1154 FileChangeTuple = namedtuple('FileChangeTuple',
1154 1155 ['added', 'modified', 'removed'])
@@ -1,106 +1,131 b''
1 1 ## -*- coding: utf-8 -*-
2 2
3 ## helpers
4 <%def name="tag_button(text, tag_type=None)">
5 <%
6 color_scheme = {
7 'default': 'border:1px solid #979797;color:#666666;background-color:#f9f9f9',
8 'approved': 'border:1px solid #0ac878;color:#0ac878;background-color:#f9f9f9',
9 'rejected': 'border:1px solid #e85e4d;color:#e85e4d;background-color:#f9f9f9',
10 'under_review': 'border:1px solid #ffc854;color:#ffc854;background-color:#f9f9f9',
11 }
12 %>
13 <pre style="display:inline;border-radius:2px;font-size:12px;padding:.2em;${color_scheme.get(tag_type, color_scheme['default'])}">${text}</pre>
14 </%def>
15
16 <%def name="status_text(text, tag_type=None)">
17 <%
18 color_scheme = {
19 'default': 'color:#666666',
20 'approved': 'color:#0ac878',
21 'rejected': 'color:#e85e4d',
22 'under_review': 'color:#ffc854',
23 }
24 %>
25 <span style="font-weight:bold;font-size:12px;padding:.2em;${color_scheme.get(tag_type, color_scheme['default'])}">${text}</span>
26 </%def>
27
3 28 ## headers we additionally can set for email
4 29 <%def name="headers()" filter="n,trim"></%def>
5 30
6 31 <%def name="plaintext_footer()">
7 32 ${_('This is a notification from RhodeCode. %(instance_url)s') % {'instance_url': instance_url}}
8 33 </%def>
9 34
10 35 <%def name="body_plaintext()" filter="n,trim">
11 36 ## this example is not called itself but overridden in each template
12 37 ## the plaintext_footer should be at the bottom of both html and text emails
13 38 ${self.plaintext_footer()}
14 39 </%def>
15 40
16 41 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
17 42 <html xmlns="http://www.w3.org/1999/xhtml">
18 43 <head>
19 44 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
20 45 <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
21 46 <title>${self.subject()}</title>
22 47 <style type="text/css">
23 48 /* Based on The MailChimp Reset INLINE: Yes. */
24 49 #outlook a {padding:0;} /* Force Outlook to provide a "view in browser" menu link. */
25 50 body{width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0;}
26 51 /* Prevent Webkit and Windows Mobile platforms from changing default font sizes.*/
27 52 .ExternalClass {width:100%;} /* Force Hotmail to display emails at full width */
28 53 .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;}
29 54 /* Forces Hotmail to display normal line spacing. More on that: http://www.emailonacid.com/forum/viewthread/43/ */
30 55 #backgroundTable {margin:0; padding:0; line-height: 100% !important;}
31 56 /* End reset */
32 57
33 58 /* defaults for images*/
34 59 img {outline:none; text-decoration:none; -ms-interpolation-mode: bicubic;}
35 60 a img {border:none;}
36 61 .image_fix {display:block;}
37 62
38 63 body {line-height:1.2em;}
39 64 p {margin: 0 0 20px;}
40 65 h1, h2, h3, h4, h5, h6 {color:#323232!important;}
41 66 a {color:#427cc9;text-decoration:none;outline:none;cursor:pointer;}
42 67 a:focus {outline:none;}
43 68 a:hover {color: #305b91;}
44 69 h1 a, h2 a, h3 a, h4 a, h5 a, h6 a {color:#427cc9!important;text-decoration:none!important;}
45 70 h1 a:active, h2 a:active, h3 a:active, h4 a:active, h5 a:active, h6 a:active {color: #305b91!important;}
46 71 h1 a:visited, h2 a:visited, h3 a:visited, h4 a:visited, h5 a:visited, h6 a:visited {color: #305b91!important;}
47 72 table {font-size:13px;border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}
48 73 table td {padding:.65em 1em .65em 0;border-collapse:collapse;vertical-align:top;text-align:left;}
49 74 input {display:inline;border-radius:2px;border-style:solid;border: 1px solid #dbd9da;padding:.5em;}
50 75 input:focus {outline: 1px solid #979797}
51 76 @media only screen and (-webkit-min-device-pixel-ratio: 2) {
52 77 /* Put your iPhone 4g styles in here */
53 78 }
54 79
55 80 /* Android targeting */
56 81 @media only screen and (-webkit-device-pixel-ratio:.75){
57 82 /* Put CSS for low density (ldpi) Android layouts in here */
58 83 }
59 84 @media only screen and (-webkit-device-pixel-ratio:1){
60 85 /* Put CSS for medium density (mdpi) Android layouts in here */
61 86 }
62 87 @media only screen and (-webkit-device-pixel-ratio:1.5){
63 88 /* Put CSS for high density (hdpi) Android layouts in here */
64 89 }
65 90 /* end Android targeting */
66 91
67 92 </style>
68 93
69 94 <!-- Targeting Windows Mobile -->
70 95 <!--[if IEMobile 7]>
71 96 <style type="text/css">
72 97
73 98 </style>
74 99 <![endif]-->
75 100
76 101 <!--[if gte mso 9]>
77 102 <style>
78 103 /* Target Outlook 2007 and 2010 */
79 104 </style>
80 105 <![endif]-->
81 106 </head>
82 107 <body>
83 108 <!-- Wrapper/Container Table: Use a wrapper table to control the width and the background color consistently of your email. Use this approach instead of setting attributes on the body tag. -->
84 109 <table cellpadding="0" cellspacing="0" border="0" id="backgroundTable" align="left" style="margin:1%;width:97%;padding:0;font-family:sans-serif;font-weight:100;border:1px solid #dbd9da">
85 110 <tr>
86 111 <td valign="top" style="padding:0;">
87 112 <table cellpadding="0" cellspacing="0" border="0" align="left" width="100%">
88 113 <tr><td style="width:100%;padding:7px;background-color:#202020" valign="top">
89 114 <a style="color:#eeeeee;text-decoration:none;" href="${instance_url}">
90 115 ${_('RhodeCode')}
91 116 % if rhodecode_instance_name:
92 117 - ${rhodecode_instance_name}
93 118 % endif
94 119 </a>
95 120 </td></tr>
96 121 <tr><td style="padding:15px;" valign="top">${self.body()}</td></tr>
97 122 </table>
98 123 </td>
99 124 </tr>
100 125 </table>
101 126 <!-- End of wrapper table -->
102 127 <p><a style="margin-top:15px;margin-left:1%;font-family:sans-serif;font-weight:100;font-size:11px;color:#666666;text-decoration:none;" href="${instance_url}">
103 128 ${self.plaintext_footer()}
104 129 </a></p>
105 130 </body>
106 131 </html>
@@ -1,88 +1,88 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3
3 <%namespace name="base" file="base.mako"/>
4 4
5 5 <%def name="subject()" filter="n,trim">
6 6 <%
7 7 data = {
8 8 'user': h.person(user),
9 9 'repo_name': repo_name,
10 10 'commit_id': h.show_id(commit),
11 11 'status': status_change,
12 12 'comment_file': comment_file,
13 13 'comment_line': comment_line,
14 14 }
15 15 %>
16 16 ${_('[mention]') if mention else ''} \
17 17
18 18 % if comment_file:
19 19 ${_('%(user)s commented on commit `%(commit_id)s` (file: `%(comment_file)s`)') % data} ${_('in the %(repo_name)s repository') % data |n}
20 20 % else:
21 21 % if status_change:
22 22 ${_('%(user)s commented on commit `%(commit_id)s` (status: %(status)s)') % data |n} ${_('in the %(repo_name)s repository') % data |n}
23 23 % else:
24 24 ${_('%(user)s commented on commit `%(commit_id)s`') % data |n} ${_('in the %(repo_name)s repository') % data |n}
25 25 % endif
26 26 % endif
27 27
28 28 </%def>
29 29
30 30 <%def name="body_plaintext()" filter="n,trim">
31 31 <%
32 32 data = {
33 33 'user': h.person(user),
34 34 'repo_name': repo_name,
35 35 'commit_id': h.show_id(commit),
36 36 'status': status_change,
37 37 'comment_file': comment_file,
38 38 'comment_line': comment_line,
39 39 }
40 40 %>
41 41 ${self.subject()}
42 42
43 43 * ${_('Comment link')}: ${commit_comment_url}
44 44
45 45 * ${_('Commit')}: ${h.show_id(commit)}
46 46
47 47 %if comment_file:
48 48 * ${_('File: %(comment_file)s on line %(comment_line)s') % {'comment_file': comment_file, 'comment_line': comment_line}}
49 49 %endif
50 50
51 51 ---
52 52
53 53 %if status_change:
54 54 ${_('Commit status was changed to')}: *${status_change}*
55 55 %endif
56 56
57 57 ${comment_body|n}
58 58
59 59 ${self.plaintext_footer()}
60 60 </%def>
61 61
62 62
63 63 <%
64 64 data = {
65 65 'user': h.person(user),
66 66 'comment_file': comment_file,
67 67 'comment_line': comment_line,
68 68 'repo': commit_target_repo,
69 69 'repo_name': repo_name,
70 70 'commit_id': h.show_id(commit),
71 71 }
72 72 %>
73 73 <table style="text-align:left;vertical-align:middle;">
74 74 <tr><td colspan="2" style="width:100%;padding-bottom:15px;border-bottom:1px solid #dbd9da;">
75 75 % if comment_file:
76 76 <h4><a href="${commit_comment_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">${_('%(user)s commented on commit `%(commit_id)s` (file:`%(comment_file)s`)') % data}</a> ${_('in the %(repo)s repository') % data |n}</h4>
77 77 % else:
78 78 <h4><a href="${commit_comment_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">${_('%(user)s commented on commit `%(commit_id)s`') % data |n}</a> ${_('in the %(repo)s repository') % data |n}</h4>
79 79 % endif
80 80 </td></tr>
81 81 <tr><td style="padding-right:20px;padding-top:15px;">${_('Commit')}</td><td style="padding-top:15px;"><a href="${commit_comment_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">${h.show_id(commit)}</a></td></tr>
82 82 <tr><td style="padding-right:20px;">${_('Description')}</td><td>${h.urlify_commit_message(commit.message, repo_name)}</td></tr>
83 83
84 84 % if status_change:
85 <tr><td style="padding-right:20px;">${_('Status')}</td><td>${_('The commit status was changed to')}: ${status_change}.</td></tr>
85 <tr><td style="padding-right:20px;">${_('Status')}</td><td>${_('The commit status was changed to')}: ${base.status_text(status_change, tag_type=status_change_type)}</td></tr>
86 86 % endif
87 87 <tr><td style="padding-right:20px;">${(_('Comment on line: %(comment_line)s') if comment_file else _('Comment')) % data}</td><td style="line-height:1.2em;">${h.render(comment_body, renderer=renderer_type, mentions=True)}</td></tr>
88 88 </table>
@@ -1,94 +1,98 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3 <%namespace name="base" file="base.mako"/>
3 4
4 5
5 6 <%def name="subject()" filter="n,trim">
6 7 <%
7 8 data = {
8 9 'user': h.person(user),
9 10 'pr_title': pull_request.title,
10 11 'pr_id': pull_request.pull_request_id,
11 12 'status': status_change,
12 13 'comment_file': comment_file,
13 14 'comment_line': comment_line,
14 15 }
15 16 %>
16 17
17 18 ${_('[mention]') if mention else ''} \
18 19
19 20 % if comment_file:
20 21 ${_('%(user)s commented on pull request #%(pr_id)s "%(pr_title)s" (file: `%(comment_file)s`)') % data |n}
21 22 % else:
22 23 % if status_change:
23 24 ${_('%(user)s commented on pull request #%(pr_id)s "%(pr_title)s" (status: %(status)s)') % data |n}
24 25 % else:
25 26 ${_('%(user)s commented on pull request #%(pr_id)s "%(pr_title)s"') % data |n}
26 27 % endif
27 28 % endif
28 29 </%def>
29 30
30 31 <%def name="body_plaintext()" filter="n,trim">
31 32 <%
32 33 data = {
33 34 'user': h.person(user),
34 35 'pr_title': pull_request.title,
35 36 'pr_id': pull_request.pull_request_id,
36 37 'status': status_change,
37 38 'comment_file': comment_file,
38 39 'comment_line': comment_line,
39 40 }
40 41 %>
41 42 ${self.subject()}
42 43
43 44 * ${_('Comment link')}: ${pr_comment_url}
44 45
45 46 * ${_('Source repository')}: ${pr_source_repo_url}
46 47
47 48 %if comment_file:
48 49 * ${_('File: %(comment_file)s on line %(comment_line)s') % {'comment_file': comment_file, 'comment_line': comment_line}}
49 50 %endif
50 51
51 52 ---
52 53
53 54 %if status_change and not closing_pr:
54 55 ${_('%(user)s submitted pull request #%(pr_id)s status: *%(status)s*') % data}
55 56 %elif status_change and closing_pr:
56 57 ${_('%(user)s submitted pull request #%(pr_id)s status: *%(status)s and closed*') % data}
57 58 %endif
58 59
59 60 ${comment_body|n}
60 61
61 62 ${self.plaintext_footer()}
62 63 </%def>
63 64
64 65
65 66 <%
66 67 data = {
67 68 'user': h.person(user),
68 69 'pr_title': pull_request.title,
69 70 'pr_id': pull_request.pull_request_id,
70 71 'status': status_change,
71 72 'comment_file': comment_file,
72 73 'comment_line': comment_line,
73 74 }
74 75 %>
75 76 <table style="text-align:left;vertical-align:middle;">
76 77 <tr><td colspan="2" style="width:100%;padding-bottom:15px;border-bottom:1px solid #dbd9da;">
77 78 <h4><a href="${pr_comment_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">
78 79
79 80 % if comment_file:
80 81 ${_('%(user)s commented on pull request #%(pr_id)s "%(pr_title)s" (file:`%(comment_file)s`)') % data |n}
81 82 % else:
82 83 ${_('%(user)s commented on pull request #%(pr_id)s "%(pr_title)s"') % data |n}
83 84 % endif
84 85 </a>
85 86 %if status_change and not closing_pr:
86 87 , ${_('submitted pull request status: %(status)s') % data}
87 88 %elif status_change and closing_pr:
88 89 , ${_('submitted pull request status: %(status)s and closed') % data}
89 90 %endif
90 91 </h4>
91 92 </td></tr>
92 93 <tr><td style="padding-right:20px;padding-top:15px;">${_('Source')}</td><td style="padding-top:15px;"><a style="color:#427cc9;text-decoration:none;cursor:pointer" href="${pr_source_repo_url}">${pr_source_repo.repo_name}</a></td></tr>
94 % if status_change:
95 <tr><td style="padding-right:20px;">${_('Submitted status')}</td><td>${base.status_text(status_change, tag_type=status_change_type)}</td></tr>
96 % endif
93 97 <tr><td style="padding-right:20px;">${(_('Comment on line: %(comment_line)s') if comment_file else _('Comment')) % data}</td><td style="line-height:1.2em;">${h.render(comment_body, renderer=renderer_type, mentions=True)}</td></tr>
94 98 </table>
@@ -1,61 +1,85 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="base.mako"/>
3 <%namespace name="base" file="base.mako"/>
3 4
4 5 <%def name="subject()" filter="n,trim">
5 ${_('%(user)s wants you to review pull request #%(pr_url)s: "%(pr_title)s"') % {
6 'user': h.person(user),
7 'pr_title': pull_request.title,
8 'pr_url': pull_request.pull_request_id
9 } |n}
6 <%
7 data = {
8 'user': h.person(user),
9 'pr_id': pull_request.pull_request_id,
10 'pr_title': pull_request.title,
11 }
12 %>
13
14 ${_('%(user)s wants you to review pull request #%(pr_id)s: "%(pr_title)s"') % data |n}
10 15 </%def>
11 16
12 17
13 18 <%def name="body_plaintext()" filter="n,trim">
19 <%
20 data = {
21 'user': h.person(user),
22 'pr_id': pull_request.pull_request_id,
23 'pr_title': pull_request.title,
24 'source_ref_type': pull_request.source_ref_parts.type,
25 'source_ref_name': pull_request.source_ref_parts.name,
26 'target_ref_type': pull_request.target_ref_parts.type,
27 'target_ref_name': pull_request.target_ref_parts.name,
28 'repo_url': pull_request_source_repo_url
29 }
30 %>
14 31 ${self.subject()}
15 32
16 33
17 ${h.literal(_('Pull request from %(source_ref_type)s:%(source_ref_name)s of %(repo_url)s into %(target_ref_type)s:%(target_ref_name)s') % {
18 'source_ref_type': pull_request.source_ref_parts.type,
19 'source_ref_name': pull_request.source_ref_parts.name,
20 'target_ref_type': pull_request.target_ref_parts.type,
21 'target_ref_name': pull_request.target_ref_parts.name,
22 'repo_url': pull_request_source_repo_url
23 })}
34 ${h.literal(_('Pull request from %(source_ref_type)s:%(source_ref_name)s of %(repo_url)s into %(target_ref_type)s:%(target_ref_name)s') % data)}
24 35
25 36
26 37 * ${_('Link')}: ${pull_request_url}
27 38
28 39 * ${_('Title')}: ${pull_request.title}
29 40
30 41 * ${_('Description')}:
31 42
32 43 ${pull_request.description}
33 44
34 45
35 46 * ${ungettext('Commit (%(num)s)', 'Commits (%(num)s)', len(pull_request_commits) ) % {'num': len(pull_request_commits)}}:
36 47
37 48 % for commit_id, message in pull_request_commits:
38 49 - ${h.short_id(commit_id)}
39 50 ${h.chop_at_smart(message, '\n', suffix_if_chopped='...')}
40 51
41 52 % endfor
42 53
43 54 ${self.plaintext_footer()}
44 55 </%def>
45
56 <%
57 data = {
58 'user': h.person(user),
59 'pr_id': pull_request.pull_request_id,
60 'pr_title': pull_request.title,
61 'source_ref_type': pull_request.source_ref_parts.type,
62 'source_ref_name': pull_request.source_ref_parts.name,
63 'target_ref_type': pull_request.target_ref_parts.type,
64 'target_ref_name': pull_request.target_ref_parts.name,
65 'repo_url': pull_request_source_repo_url,
66 'source_repo_url': h.link_to(pull_request_source_repo.repo_name, pull_request_source_repo_url),
67 'target_repo_url': h.link_to(pull_request_target_repo.repo_name, pull_request_target_repo_url)
68 }
69 %>
46 70 <table style="text-align:left;vertical-align:middle;">
47 <tr><td colspan="2" style="width:100%;padding-bottom:15px;border-bottom:1px solid #dbd9da;"><h4><a href="${pull_request_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">${_('%(user)s wants you to review pull request #%(pr_id)s: "%(pr_title)s".') % { 'user': h.person(user), 'pr_title': pull_request.title, 'pr_id': pull_request.pull_request_id } }</a></h4></td></tr>
71 <tr><td colspan="2" style="width:100%;padding-bottom:15px;border-bottom:1px solid #dbd9da;"><h4><a href="${pull_request_url}" style="color:#427cc9;text-decoration:none;cursor:pointer">${_('%(user)s wants you to review pull request #%(pr_id)s: "%(pr_title)s".') % data }</a></h4></td></tr>
48 72 <tr><td style="padding-right:20px;padding-top:15px;">${_('Title')}</td><td style="padding-top:15px;">${pull_request.title}</td></tr>
49 <tr><td style="padding-right:20px;">${_('Source')}</td><td><pre style="display:inline;border-radius:2px;color:#666666;font-size:12px;background-color:#f9f9f9;padding:.2em;border:1px solid #979797;">${pull_request.source_ref_parts.name}</pre>${h.literal(_('%(source_ref_type)s of %(source_repo_url)s') % {'source_ref_type': pull_request.source_ref_parts.type, 'source_repo_url': h.link_to(pull_request_source_repo.repo_name, pull_request_source_repo_url)})}</td></tr>
50 <tr><td style="padding-right:20px;">${_('Target')}</td><td><pre style="display:inline;border-radius:2px;color:#666666;font-size:12px;background-color:#f9f9f9;padding:.2em;border:1px solid #979797;">${pull_request.target_ref_parts.name}</pre>${h.literal(_('%(target_ref_type)s of %(target_repo_url)s') % {'target_ref_type': pull_request.target_ref_parts.type, 'target_repo_url': h.link_to(pull_request_target_repo.repo_name, pull_request_target_repo_url)})}</td></tr>
73 <tr><td style="padding-right:20px;">${_('Source')}</td><td>${base.tag_button(pull_request.source_ref_parts.name)} ${h.literal(_('%(source_ref_type)s of %(source_repo_url)s') % data)}</td></tr>
74 <tr><td style="padding-right:20px;">${_('Target')}</td><td>${base.tag_button(pull_request.target_ref_parts.name)} ${h.literal(_('%(target_ref_type)s of %(target_repo_url)s') % data)}</td></tr>
51 75 <tr><td style="padding-right:20px;">${_('Description')}</td><td style="white-space:pre-wrap">${pull_request.description}</td></tr>
52 76 <tr><td style="padding-right:20px;">${ungettext('%(num)s Commit', '%(num)s Commits', len(pull_request_commits)) % {'num': len(pull_request_commits)}}</td>
53 77 <td><ol style="margin:0 0 0 1em;padding:0;text-align:left;">
54 % for commit_id, message in pull_request_commits:
55 <li style="margin:0 0 1em;"><pre style="margin:0 0 .5em">${h.short_id(commit_id)}</pre>
56 ${h.chop_at_smart(message, '\n', suffix_if_chopped='...')}
57 </li>
58 % endfor
78 % for commit_id, message in pull_request_commits:
79 <li style="margin:0 0 1em;"><pre style="margin:0 0 .5em">${h.short_id(commit_id)}</pre>
80 ${h.chop_at_smart(message, '\n', suffix_if_chopped='...')}
81 </li>
82 % endfor
59 83 </ol></td>
60 84 </tr>
61 85 </table>
General Comments 0
You need to be logged in to leave comments. Login now