##// END OF EJS Templates
api: Add an entry for pr shadow repositories to api functions.
Martin Bornhold -
r893:b7927ff7 default
parent child Browse files
Show More

The requested changes are too big and content was truncated. Show full diff

@@ -1,660 +1,666 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 "shadow": {
100 "clone_url": "<clone_url>",
101 },
99 102 "author": <user_obj>,
100 103 "reviewers": [
101 104 ...
102 105 {
103 106 "user": "<user_obj>",
104 107 "review_status": "<review_status>",
105 108 }
106 109 ...
107 110 ]
108 111 },
109 112 "error": null
110 113 """
111 114 get_repo_or_error(repoid)
112 115 pull_request = get_pull_request_or_error(pullrequestid)
113 116 if not PullRequestModel().check_user_read(
114 117 pull_request, apiuser, api=True):
115 118 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
116 119 data = pull_request.get_api_data()
117 120 return data
118 121
119 122
120 123 @jsonrpc_method()
121 124 def get_pull_requests(request, apiuser, repoid, status=Optional('new')):
122 125 """
123 126 Get all pull requests from the repository specified in `repoid`.
124 127
125 128 :param apiuser: This is filled automatically from the |authtoken|.
126 129 :type apiuser: AuthUser
127 130 :param repoid: Repository name or repository ID.
128 131 :type repoid: str or int
129 132 :param status: Only return pull requests with the specified status.
130 133 Valid options are.
131 134 * ``new`` (default)
132 135 * ``open``
133 136 * ``closed``
134 137 :type status: str
135 138
136 139 Example output:
137 140
138 141 .. code-block:: bash
139 142
140 143 "id": <id_given_in_input>,
141 144 "result":
142 145 [
143 146 ...
144 147 {
145 148 "pull_request_id": "<pull_request_id>",
146 149 "url": "<url>",
147 150 "title" : "<title>",
148 151 "description": "<description>",
149 152 "status": "<status>",
150 153 "created_on": "<date_time_created>",
151 154 "updated_on": "<date_time_updated>",
152 155 "commit_ids": [
153 156 ...
154 157 "<commit_id>",
155 158 "<commit_id>",
156 159 ...
157 160 ],
158 161 "review_status": "<review_status>",
159 162 "mergeable": {
160 163 "status": "<bool>",
161 164 "message: "<message>",
162 165 },
163 166 "source": {
164 167 "clone_url": "<clone_url>",
165 168 "reference":
166 169 {
167 170 "name": "<name>",
168 171 "type": "<type>",
169 172 "commit_id": "<commit_id>",
170 173 }
171 174 },
172 175 "target": {
173 176 "clone_url": "<clone_url>",
174 177 "reference":
175 178 {
176 179 "name": "<name>",
177 180 "type": "<type>",
178 181 "commit_id": "<commit_id>",
179 182 }
180 183 },
184 "shadow": {
185 "clone_url": "<clone_url>",
186 },
181 187 "author": <user_obj>,
182 188 "reviewers": [
183 189 ...
184 190 {
185 191 "user": "<user_obj>",
186 192 "review_status": "<review_status>",
187 193 }
188 194 ...
189 195 ]
190 196 }
191 197 ...
192 198 ],
193 199 "error": null
194 200
195 201 """
196 202 repo = get_repo_or_error(repoid)
197 203 if not has_superadmin_permission(apiuser):
198 204 _perms = (
199 205 'repository.admin', 'repository.write', 'repository.read',)
200 206 has_repo_permissions(apiuser, repoid, repo, _perms)
201 207
202 208 status = Optional.extract(status)
203 209 pull_requests = PullRequestModel().get_all(repo, statuses=[status])
204 210 data = [pr.get_api_data() for pr in pull_requests]
205 211 return data
206 212
207 213
208 214 @jsonrpc_method()
209 215 def merge_pull_request(request, apiuser, repoid, pullrequestid,
210 216 userid=Optional(OAttr('apiuser'))):
211 217 """
212 218 Merge the pull request specified by `pullrequestid` into its target
213 219 repository.
214 220
215 221 :param apiuser: This is filled automatically from the |authtoken|.
216 222 :type apiuser: AuthUser
217 223 :param repoid: The Repository name or repository ID of the
218 224 target repository to which the |pr| is to be merged.
219 225 :type repoid: str or int
220 226 :param pullrequestid: ID of the pull request which shall be merged.
221 227 :type pullrequestid: int
222 228 :param userid: Merge the pull request as this user.
223 229 :type userid: Optional(str or int)
224 230
225 231 Example output:
226 232
227 233 .. code-block:: bash
228 234
229 235 "id": <id_given_in_input>,
230 236 "result":
231 237 {
232 238 "executed": "<bool>",
233 239 "failure_reason": "<int>",
234 240 "merge_commit_id": "<merge_commit_id>",
235 241 "possible": "<bool>"
236 242 },
237 243 "error": null
238 244
239 245 """
240 246 repo = get_repo_or_error(repoid)
241 247 if not isinstance(userid, Optional):
242 248 if (has_superadmin_permission(apiuser) or
243 249 HasRepoPermissionAnyApi('repository.admin')(
244 250 user=apiuser, repo_name=repo.repo_name)):
245 251 apiuser = get_user_or_error(userid)
246 252 else:
247 253 raise JSONRPCError('userid is not the same as your user')
248 254
249 255 pull_request = get_pull_request_or_error(pullrequestid)
250 256 if not PullRequestModel().check_user_merge(
251 257 pull_request, apiuser, api=True):
252 258 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
253 259 if pull_request.is_closed():
254 260 raise JSONRPCError(
255 261 'pull request `%s` merge failed, pull request is closed' % (
256 262 pullrequestid,))
257 263
258 264 target_repo = pull_request.target_repo
259 265 extras = vcs_operation_context(
260 266 request.environ, repo_name=target_repo.repo_name,
261 267 username=apiuser.username, action='push',
262 268 scm=target_repo.repo_type)
263 269 data = PullRequestModel().merge(pull_request, apiuser, extras=extras)
264 270 if data.executed:
265 271 PullRequestModel().close_pull_request(
266 272 pull_request.pull_request_id, apiuser)
267 273
268 274 Session().commit()
269 275 return data
270 276
271 277
272 278 @jsonrpc_method()
273 279 def close_pull_request(request, apiuser, repoid, pullrequestid,
274 280 userid=Optional(OAttr('apiuser'))):
275 281 """
276 282 Close the pull request specified by `pullrequestid`.
277 283
278 284 :param apiuser: This is filled automatically from the |authtoken|.
279 285 :type apiuser: AuthUser
280 286 :param repoid: Repository name or repository ID to which the pull
281 287 request belongs.
282 288 :type repoid: str or int
283 289 :param pullrequestid: ID of the pull request to be closed.
284 290 :type pullrequestid: int
285 291 :param userid: Close the pull request as this user.
286 292 :type userid: Optional(str or int)
287 293
288 294 Example output:
289 295
290 296 .. code-block:: bash
291 297
292 298 "id": <id_given_in_input>,
293 299 "result":
294 300 {
295 301 "pull_request_id": "<int>",
296 302 "closed": "<bool>"
297 303 },
298 304 "error": null
299 305
300 306 """
301 307 repo = get_repo_or_error(repoid)
302 308 if not isinstance(userid, Optional):
303 309 if (has_superadmin_permission(apiuser) or
304 310 HasRepoPermissionAnyApi('repository.admin')(
305 311 user=apiuser, repo_name=repo.repo_name)):
306 312 apiuser = get_user_or_error(userid)
307 313 else:
308 314 raise JSONRPCError('userid is not the same as your user')
309 315
310 316 pull_request = get_pull_request_or_error(pullrequestid)
311 317 if not PullRequestModel().check_user_update(
312 318 pull_request, apiuser, api=True):
313 319 raise JSONRPCError(
314 320 'pull request `%s` close failed, no permission to close.' % (
315 321 pullrequestid,))
316 322 if pull_request.is_closed():
317 323 raise JSONRPCError(
318 324 'pull request `%s` is already closed' % (pullrequestid,))
319 325
320 326 PullRequestModel().close_pull_request(
321 327 pull_request.pull_request_id, apiuser)
322 328 Session().commit()
323 329 data = {
324 330 'pull_request_id': pull_request.pull_request_id,
325 331 'closed': True,
326 332 }
327 333 return data
328 334
329 335
330 336 @jsonrpc_method()
331 337 def comment_pull_request(request, apiuser, repoid, pullrequestid,
332 338 message=Optional(None), status=Optional(None),
333 339 userid=Optional(OAttr('apiuser'))):
334 340 """
335 341 Comment on the pull request specified with the `pullrequestid`,
336 342 in the |repo| specified by the `repoid`, and optionally change the
337 343 review status.
338 344
339 345 :param apiuser: This is filled automatically from the |authtoken|.
340 346 :type apiuser: AuthUser
341 347 :param repoid: The repository name or repository ID.
342 348 :type repoid: str or int
343 349 :param pullrequestid: The pull request ID.
344 350 :type pullrequestid: int
345 351 :param message: The text content of the comment.
346 352 :type message: str
347 353 :param status: (**Optional**) Set the approval status of the pull
348 354 request. Valid options are:
349 355 * not_reviewed
350 356 * approved
351 357 * rejected
352 358 * under_review
353 359 :type status: str
354 360 :param userid: Comment on the pull request as this user
355 361 :type userid: Optional(str or int)
356 362
357 363 Example output:
358 364
359 365 .. code-block:: bash
360 366
361 367 id : <id_given_in_input>
362 368 result :
363 369 {
364 370 "pull_request_id": "<Integer>",
365 371 "comment_id": "<Integer>"
366 372 }
367 373 error : null
368 374 """
369 375 repo = get_repo_or_error(repoid)
370 376 if not isinstance(userid, Optional):
371 377 if (has_superadmin_permission(apiuser) or
372 378 HasRepoPermissionAnyApi('repository.admin')(
373 379 user=apiuser, repo_name=repo.repo_name)):
374 380 apiuser = get_user_or_error(userid)
375 381 else:
376 382 raise JSONRPCError('userid is not the same as your user')
377 383
378 384 pull_request = get_pull_request_or_error(pullrequestid)
379 385 if not PullRequestModel().check_user_read(
380 386 pull_request, apiuser, api=True):
381 387 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
382 388 message = Optional.extract(message)
383 389 status = Optional.extract(status)
384 390 if not message and not status:
385 391 raise JSONRPCError('message and status parameter missing')
386 392
387 393 if (status not in (st[0] for st in ChangesetStatus.STATUSES) and
388 394 status is not None):
389 395 raise JSONRPCError('unknown comment status`%s`' % status)
390 396
391 397 allowed_to_change_status = PullRequestModel().check_user_change_status(
392 398 pull_request, apiuser)
393 399 text = message
394 400 if status and allowed_to_change_status:
395 401 st_message = (('Status change %(transition_icon)s %(status)s')
396 402 % {'transition_icon': '>',
397 403 'status': ChangesetStatus.get_status_lbl(status)})
398 404 text = message or st_message
399 405
400 406 rc_config = SettingsModel().get_all_settings()
401 407 renderer = rc_config.get('rhodecode_markup_renderer', 'rst')
402 408 comment = ChangesetCommentsModel().create(
403 409 text=text,
404 410 repo=pull_request.target_repo.repo_id,
405 411 user=apiuser.user_id,
406 412 pull_request=pull_request.pull_request_id,
407 413 f_path=None,
408 414 line_no=None,
409 415 status_change=(ChangesetStatus.get_status_lbl(status)
410 416 if status and allowed_to_change_status else None),
411 417 status_change_type=(status
412 418 if status and allowed_to_change_status else None),
413 419 closing_pr=False,
414 420 renderer=renderer
415 421 )
416 422
417 423 if allowed_to_change_status and status:
418 424 ChangesetStatusModel().set_status(
419 425 pull_request.target_repo.repo_id,
420 426 status,
421 427 apiuser.user_id,
422 428 comment,
423 429 pull_request=pull_request.pull_request_id
424 430 )
425 431 Session().flush()
426 432
427 433 Session().commit()
428 434 data = {
429 435 'pull_request_id': pull_request.pull_request_id,
430 436 'comment_id': comment.comment_id,
431 437 'status': status
432 438 }
433 439 return data
434 440
435 441
436 442 @jsonrpc_method()
437 443 def create_pull_request(
438 444 request, apiuser, source_repo, target_repo, source_ref, target_ref,
439 445 title, description=Optional(''), reviewers=Optional(None)):
440 446 """
441 447 Creates a new pull request.
442 448
443 449 Accepts refs in the following formats:
444 450
445 451 * branch:<branch_name>:<sha>
446 452 * branch:<branch_name>
447 453 * bookmark:<bookmark_name>:<sha> (Mercurial only)
448 454 * bookmark:<bookmark_name> (Mercurial only)
449 455
450 456 :param apiuser: This is filled automatically from the |authtoken|.
451 457 :type apiuser: AuthUser
452 458 :param source_repo: Set the source repository name.
453 459 :type source_repo: str
454 460 :param target_repo: Set the target repository name.
455 461 :type target_repo: str
456 462 :param source_ref: Set the source ref name.
457 463 :type source_ref: str
458 464 :param target_ref: Set the target ref name.
459 465 :type target_ref: str
460 466 :param title: Set the pull request title.
461 467 :type title: str
462 468 :param description: Set the pull request description.
463 469 :type description: Optional(str)
464 470 :param reviewers: Set the new pull request reviewers list.
465 471 :type reviewers: Optional(list)
466 472 Accepts username strings or objects of the format:
467 473 {
468 474 'username': 'nick', 'reasons': ['original author']
469 475 }
470 476 """
471 477
472 478 source = get_repo_or_error(source_repo)
473 479 target = get_repo_or_error(target_repo)
474 480 if not has_superadmin_permission(apiuser):
475 481 _perms = ('repository.admin', 'repository.write', 'repository.read',)
476 482 has_repo_permissions(apiuser, source_repo, source, _perms)
477 483
478 484 full_source_ref = resolve_ref_or_error(source_ref, source)
479 485 full_target_ref = resolve_ref_or_error(target_ref, target)
480 486 source_commit = get_commit_or_error(full_source_ref, source)
481 487 target_commit = get_commit_or_error(full_target_ref, target)
482 488 source_scm = source.scm_instance()
483 489 target_scm = target.scm_instance()
484 490
485 491 commit_ranges = target_scm.compare(
486 492 target_commit.raw_id, source_commit.raw_id, source_scm,
487 493 merge=True, pre_load=[])
488 494
489 495 ancestor = target_scm.get_common_ancestor(
490 496 target_commit.raw_id, source_commit.raw_id, source_scm)
491 497
492 498 if not commit_ranges:
493 499 raise JSONRPCError('no commits found')
494 500
495 501 if not ancestor:
496 502 raise JSONRPCError('no common ancestor found')
497 503
498 504 reviewer_objects = Optional.extract(reviewers) or []
499 505 if not isinstance(reviewer_objects, list):
500 506 raise JSONRPCError('reviewers should be specified as a list')
501 507
502 508 reviewers_reasons = []
503 509 for reviewer_object in reviewer_objects:
504 510 reviewer_reasons = []
505 511 if isinstance(reviewer_object, (basestring, int)):
506 512 reviewer_username = reviewer_object
507 513 else:
508 514 reviewer_username = reviewer_object['username']
509 515 reviewer_reasons = reviewer_object.get('reasons', [])
510 516
511 517 user = get_user_or_error(reviewer_username)
512 518 reviewers_reasons.append((user.user_id, reviewer_reasons))
513 519
514 520 pull_request_model = PullRequestModel()
515 521 pull_request = pull_request_model.create(
516 522 created_by=apiuser.user_id,
517 523 source_repo=source_repo,
518 524 source_ref=full_source_ref,
519 525 target_repo=target_repo,
520 526 target_ref=full_target_ref,
521 527 revisions=reversed(
522 528 [commit.raw_id for commit in reversed(commit_ranges)]),
523 529 reviewers=reviewers_reasons,
524 530 title=title,
525 531 description=Optional.extract(description)
526 532 )
527 533
528 534 Session().commit()
529 535 data = {
530 536 'msg': 'Created new pull request `{}`'.format(title),
531 537 'pull_request_id': pull_request.pull_request_id,
532 538 }
533 539 return data
534 540
535 541
536 542 @jsonrpc_method()
537 543 def update_pull_request(
538 544 request, apiuser, repoid, pullrequestid, title=Optional(''),
539 545 description=Optional(''), reviewers=Optional(None),
540 546 update_commits=Optional(None), close_pull_request=Optional(None)):
541 547 """
542 548 Updates a pull request.
543 549
544 550 :param apiuser: This is filled automatically from the |authtoken|.
545 551 :type apiuser: AuthUser
546 552 :param repoid: The repository name or repository ID.
547 553 :type repoid: str or int
548 554 :param pullrequestid: The pull request ID.
549 555 :type pullrequestid: int
550 556 :param title: Set the pull request title.
551 557 :type title: str
552 558 :param description: Update pull request description.
553 559 :type description: Optional(str)
554 560 :param reviewers: Update pull request reviewers list with new value.
555 561 :type reviewers: Optional(list)
556 562 :param update_commits: Trigger update of commits for this pull request
557 563 :type: update_commits: Optional(bool)
558 564 :param close_pull_request: Close this pull request with rejected state
559 565 :type: close_pull_request: Optional(bool)
560 566
561 567 Example output:
562 568
563 569 .. code-block:: bash
564 570
565 571 id : <id_given_in_input>
566 572 result :
567 573 {
568 574 "msg": "Updated pull request `63`",
569 575 "pull_request": <pull_request_object>,
570 576 "updated_reviewers": {
571 577 "added": [
572 578 "username"
573 579 ],
574 580 "removed": []
575 581 },
576 582 "updated_commits": {
577 583 "added": [
578 584 "<sha1_hash>"
579 585 ],
580 586 "common": [
581 587 "<sha1_hash>",
582 588 "<sha1_hash>",
583 589 ],
584 590 "removed": []
585 591 }
586 592 }
587 593 error : null
588 594 """
589 595
590 596 repo = get_repo_or_error(repoid)
591 597 pull_request = get_pull_request_or_error(pullrequestid)
592 598 if not PullRequestModel().check_user_update(
593 599 pull_request, apiuser, api=True):
594 600 raise JSONRPCError(
595 601 'pull request `%s` update failed, no permission to update.' % (
596 602 pullrequestid,))
597 603 if pull_request.is_closed():
598 604 raise JSONRPCError(
599 605 'pull request `%s` update failed, pull request is closed' % (
600 606 pullrequestid,))
601 607
602 608 reviewer_objects = Optional.extract(reviewers) or []
603 609 if not isinstance(reviewer_objects, list):
604 610 raise JSONRPCError('reviewers should be specified as a list')
605 611
606 612 reviewers_reasons = []
607 613 reviewer_ids = set()
608 614 for reviewer_object in reviewer_objects:
609 615 reviewer_reasons = []
610 616 if isinstance(reviewer_object, (int, basestring)):
611 617 reviewer_username = reviewer_object
612 618 else:
613 619 reviewer_username = reviewer_object['username']
614 620 reviewer_reasons = reviewer_object.get('reasons', [])
615 621
616 622 user = get_user_or_error(reviewer_username)
617 623 reviewer_ids.add(user.user_id)
618 624 reviewers_reasons.append((user.user_id, reviewer_reasons))
619 625
620 626 title = Optional.extract(title)
621 627 description = Optional.extract(description)
622 628 if title or description:
623 629 PullRequestModel().edit(
624 630 pull_request, title or pull_request.title,
625 631 description or pull_request.description)
626 632 Session().commit()
627 633
628 634 commit_changes = {"added": [], "common": [], "removed": []}
629 635 if str2bool(Optional.extract(update_commits)):
630 636 if PullRequestModel().has_valid_update_type(pull_request):
631 637 _version, _commit_changes = PullRequestModel().update_commits(
632 638 pull_request)
633 639 commit_changes = _commit_changes or commit_changes
634 640 Session().commit()
635 641
636 642 reviewers_changes = {"added": [], "removed": []}
637 643 if reviewer_ids:
638 644 added_reviewers, removed_reviewers = \
639 645 PullRequestModel().update_reviewers(pull_request, reviewers_reasons)
640 646
641 647 reviewers_changes['added'] = sorted(
642 648 [get_user_or_error(n).username for n in added_reviewers])
643 649 reviewers_changes['removed'] = sorted(
644 650 [get_user_or_error(n).username for n in removed_reviewers])
645 651 Session().commit()
646 652
647 653 if str2bool(Optional.extract(close_pull_request)):
648 654 PullRequestModel().close_pull_request_with_comment(
649 655 pull_request, apiuser, repo)
650 656 Session().commit()
651 657
652 658 data = {
653 659 'msg': 'Updated pull request `{}`'.format(
654 660 pull_request.pull_request_id),
655 661 'pull_request': pull_request.get_api_data(),
656 662 'updated_commits': commit_changes,
657 663 'updated_reviewers': reviewers_changes
658 664 }
659 665 return data
660 666
@@ -1,509 +1,512 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-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 SimpleVCS middleware for handling protocol request (push/clone etc.)
23 23 It's implemented with basic auth function
24 24 """
25 25
26 26 import os
27 27 import logging
28 28 import importlib
29 29 import re
30 30 from functools import wraps
31 31
32 32 from paste.httpheaders import REMOTE_USER, AUTH_TYPE
33 33 from webob.exc import (
34 34 HTTPNotFound, HTTPForbidden, HTTPNotAcceptable, HTTPInternalServerError)
35 35
36 36 import rhodecode
37 37 from rhodecode.authentication.base import authenticate, VCS_TYPE
38 38 from rhodecode.lib.auth import AuthUser, HasPermissionAnyMiddleware
39 39 from rhodecode.lib.base import BasicAuth, get_ip_addr, vcs_operation_context
40 40 from rhodecode.lib.exceptions import (
41 41 HTTPLockedRC, HTTPRequirementError, UserCreationError,
42 42 NotAllowedToCreateUserError)
43 43 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
44 44 from rhodecode.lib.middleware import appenlight
45 45 from rhodecode.lib.middleware.utils import scm_app
46 46 from rhodecode.lib.utils import (
47 47 is_valid_repo, get_rhodecode_realm, get_rhodecode_base_path)
48 48 from rhodecode.lib.utils2 import safe_str, fix_PATH, str2bool
49 49 from rhodecode.lib.vcs.conf import settings as vcs_settings
50 50 from rhodecode.lib.vcs.backends import base
51 51 from rhodecode.model import meta
52 52 from rhodecode.model.db import User, Repository
53 53 from rhodecode.model.scm import ScmModel
54 54
55 55
56 56 log = logging.getLogger(__name__)
57 57
58 58
59 59 def initialize_generator(factory):
60 60 """
61 61 Initializes the returned generator by draining its first element.
62 62
63 63 This can be used to give a generator an initializer, which is the code
64 64 up to the first yield statement. This decorator enforces that the first
65 65 produced element has the value ``"__init__"`` to make its special
66 66 purpose very explicit in the using code.
67 67 """
68 68
69 69 @wraps(factory)
70 70 def wrapper(*args, **kwargs):
71 71 gen = factory(*args, **kwargs)
72 72 try:
73 73 init = gen.next()
74 74 except StopIteration:
75 75 raise ValueError('Generator must yield at least one element.')
76 76 if init != "__init__":
77 77 raise ValueError('First yielded element must be "__init__".')
78 78 return gen
79 79 return wrapper
80 80
81 81
82 82 class SimpleVCS(object):
83 83 """Common functionality for SCM HTTP handlers."""
84 84
85 85 SCM = 'unknown'
86 86
87 87 acl_repo_name = None
88 88 url_repo_name = None
89 89 vcs_repo_name = None
90 90
91 91 def __init__(self, application, config, registry):
92 92 self.registry = registry
93 93 self.application = application
94 94 self.config = config
95 95 # re-populated by specialized middleware
96 96 self.repo_vcs_config = base.Config()
97 97
98 98 # base path of repo locations
99 99 self.basepath = get_rhodecode_base_path()
100 100 # authenticate this VCS request using authfunc
101 101 auth_ret_code_detection = \
102 102 str2bool(self.config.get('auth_ret_code_detection', False))
103 103 self.authenticate = BasicAuth(
104 104 '', authenticate, registry, config.get('auth_ret_code'),
105 105 auth_ret_code_detection)
106 106 self.ip_addr = '0.0.0.0'
107 107
108 108 def set_repo_names(self, environ):
109 109 """
110 110 This will populate the attributes acl_repo_name, url_repo_name,
111 111 vcs_repo_name and pr_id on the current instance.
112 112 """
113 # TODO: martinb: Unify generation/suffix of clone url. It is currently
114 # used here in the regex, in PullRequest in get_api_data() and
115 # indirectly in routing configuration.
113 116 # TODO: martinb: Move to class or module scope.
114 117 # TODO: martinb: Check if we have to use re.UNICODE.
115 118 # TODO: martinb: Check which chars are allowed for repo/group names.
116 119 # These chars are excluded: '`?=[]\;\'"<>,/~!@#$%^&*()+{}|: '
117 120 # Code from: rhodecode/lib/utils.py:repo_name_slug()
118 121 pr_regex = re.compile(
119 122 '(?P<base_name>(?:[\w-]+)(?:/[\w-]+)*)/' # repo groups
120 123 '(?P<repo_name>[\w-]+)' # target repo name
121 124 '/pull-request/(?P<pr_id>\d+)/repository') # pr suffix
122 125
123 126 # Get url repo name from environment.
124 127 self.url_repo_name = self._get_repository_name(environ)
125 128
126 129 # Check if this is a request to a shadow repository. In case of a
127 130 # shadow repo set vcs_repo_name to the file system path pointing to the
128 131 # shadow repo. And set acl_repo_name to the pull request target repo
129 132 # because we use the target repo for permission checks. Otherwise all
130 133 # names are equal.
131 134 match = pr_regex.match(self.url_repo_name)
132 135 if match:
133 136 # Get pull request instance.
134 137 match_dict = match.groupdict()
135 138 pr_id = match_dict['pr_id']
136 139 pull_request = PullRequest.get(pr_id)
137 140
138 141 # Get file system path to shadow repository.
139 142 workspace_id = PullRequestModel()._workspace_id(pull_request)
140 143 target_vcs = pull_request.target_repo.scm_instance()
141 144 vcs_repo_name = target_vcs._get_shadow_repository_path(
142 145 workspace_id)
143 146
144 147 # Store names for later usage.
145 148 self.pr_id = pr_id
146 149 self.vcs_repo_name = vcs_repo_name
147 150 self.acl_repo_name = pull_request.target_repo.repo_name
148 151 else:
149 152 # All names are equal for normal (non shadow) repositories.
150 153 self.acl_repo_name = self.url_repo_name
151 154 self.vcs_repo_name = self.url_repo_name
152 155 self.pr_id = None
153 156
154 157 @property
155 158 def repo_name(self):
156 159 # TODO: johbo: Remove, switch to correct repo name attribute
157 160 return self.acl_repo_name
158 161
159 162 @property
160 163 def scm_app(self):
161 164 custom_implementation = self.config.get('vcs.scm_app_implementation')
162 165 if custom_implementation and custom_implementation != 'pyro4':
163 166 log.info(
164 167 "Using custom implementation of scm_app: %s",
165 168 custom_implementation)
166 169 scm_app_impl = importlib.import_module(custom_implementation)
167 170 else:
168 171 scm_app_impl = scm_app
169 172 return scm_app_impl
170 173
171 174 def _get_by_id(self, repo_name):
172 175 """
173 176 Gets a special pattern _<ID> from clone url and tries to replace it
174 177 with a repository_name for support of _<ID> non changeable urls
175 178 """
176 179
177 180 data = repo_name.split('/')
178 181 if len(data) >= 2:
179 182 from rhodecode.model.repo import RepoModel
180 183 by_id_match = RepoModel().get_repo_by_id(repo_name)
181 184 if by_id_match:
182 185 data[1] = by_id_match.repo_name
183 186
184 187 return safe_str('/'.join(data))
185 188
186 189 def _invalidate_cache(self, repo_name):
187 190 """
188 191 Set's cache for this repository for invalidation on next access
189 192
190 193 :param repo_name: full repo name, also a cache key
191 194 """
192 195 ScmModel().mark_for_invalidation(repo_name)
193 196
194 197 def is_valid_and_existing_repo(self, repo_name, base_path, scm_type):
195 198 db_repo = Repository.get_by_repo_name(repo_name)
196 199 if not db_repo:
197 200 log.debug('Repository `%s` not found inside the database.',
198 201 repo_name)
199 202 return False
200 203
201 204 if db_repo.repo_type != scm_type:
202 205 log.warning(
203 206 'Repository `%s` have incorrect scm_type, expected %s got %s',
204 207 repo_name, db_repo.repo_type, scm_type)
205 208 return False
206 209
207 210 return is_valid_repo(repo_name, base_path, expect_scm=scm_type)
208 211
209 212 def valid_and_active_user(self, user):
210 213 """
211 214 Checks if that user is not empty, and if it's actually object it checks
212 215 if he's active.
213 216
214 217 :param user: user object or None
215 218 :return: boolean
216 219 """
217 220 if user is None:
218 221 return False
219 222
220 223 elif user.active:
221 224 return True
222 225
223 226 return False
224 227
225 228 def _check_permission(self, action, user, repo_name, ip_addr=None):
226 229 """
227 230 Checks permissions using action (push/pull) user and repository
228 231 name
229 232
230 233 :param action: push or pull action
231 234 :param user: user instance
232 235 :param repo_name: repository name
233 236 """
234 237 # check IP
235 238 inherit = user.inherit_default_permissions
236 239 ip_allowed = AuthUser.check_ip_allowed(user.user_id, ip_addr,
237 240 inherit_from_default=inherit)
238 241 if ip_allowed:
239 242 log.info('Access for IP:%s allowed', ip_addr)
240 243 else:
241 244 return False
242 245
243 246 if action == 'push':
244 247 if not HasPermissionAnyMiddleware('repository.write',
245 248 'repository.admin')(user,
246 249 repo_name):
247 250 return False
248 251
249 252 else:
250 253 # any other action need at least read permission
251 254 if not HasPermissionAnyMiddleware('repository.read',
252 255 'repository.write',
253 256 'repository.admin')(user,
254 257 repo_name):
255 258 return False
256 259
257 260 return True
258 261
259 262 def _check_ssl(self, environ, start_response):
260 263 """
261 264 Checks the SSL check flag and returns False if SSL is not present
262 265 and required True otherwise
263 266 """
264 267 org_proto = environ['wsgi._org_proto']
265 268 # check if we have SSL required ! if not it's a bad request !
266 269 require_ssl = str2bool(self.repo_vcs_config.get('web', 'push_ssl'))
267 270 if require_ssl and org_proto == 'http':
268 271 log.debug('proto is %s and SSL is required BAD REQUEST !',
269 272 org_proto)
270 273 return False
271 274 return True
272 275
273 276 def __call__(self, environ, start_response):
274 277 try:
275 278 return self._handle_request(environ, start_response)
276 279 except Exception:
277 280 log.exception("Exception while handling request")
278 281 appenlight.track_exception(environ)
279 282 return HTTPInternalServerError()(environ, start_response)
280 283 finally:
281 284 meta.Session.remove()
282 285
283 286 def _handle_request(self, environ, start_response):
284 287
285 288 if not self._check_ssl(environ, start_response):
286 289 reason = ('SSL required, while RhodeCode was unable '
287 290 'to detect this as SSL request')
288 291 log.debug('User not allowed to proceed, %s', reason)
289 292 return HTTPNotAcceptable(reason)(environ, start_response)
290 293
291 294 if not self.repo_name:
292 295 log.warning('Repository name is empty: %s', self.repo_name)
293 296 # failed to get repo name, we fail now
294 297 return HTTPNotFound()(environ, start_response)
295 298 log.debug('Extracted repo name is %s', self.repo_name)
296 299
297 300 ip_addr = get_ip_addr(environ)
298 301 username = None
299 302
300 303 # skip passing error to error controller
301 304 environ['pylons.status_code_redirect'] = True
302 305
303 306 # ======================================================================
304 307 # GET ACTION PULL or PUSH
305 308 # ======================================================================
306 309 action = self._get_action(environ)
307 310
308 311 # ======================================================================
309 312 # Check if this is a request to a shadow repository of a pull request.
310 313 # In this case only pull action is allowed.
311 314 # ======================================================================
312 315 if self.pr_id is not None and action != 'pull':
313 316 reason = 'Only pull action is allowed for shadow repositories.'
314 317 log.debug('User not allowed to proceed, %s', reason)
315 318 return HTTPNotAcceptable(reason)(environ, start_response)
316 319
317 320 # ======================================================================
318 321 # CHECK ANONYMOUS PERMISSION
319 322 # ======================================================================
320 323 if action in ['pull', 'push']:
321 324 anonymous_user = User.get_default_user()
322 325 username = anonymous_user.username
323 326 if anonymous_user.active:
324 327 # ONLY check permissions if the user is activated
325 328 anonymous_perm = self._check_permission(
326 329 action, anonymous_user, self.repo_name, ip_addr)
327 330 else:
328 331 anonymous_perm = False
329 332
330 333 if not anonymous_user.active or not anonymous_perm:
331 334 if not anonymous_user.active:
332 335 log.debug('Anonymous access is disabled, running '
333 336 'authentication')
334 337
335 338 if not anonymous_perm:
336 339 log.debug('Not enough credentials to access this '
337 340 'repository as anonymous user')
338 341
339 342 username = None
340 343 # ==============================================================
341 344 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
342 345 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
343 346 # ==============================================================
344 347
345 348 # try to auth based on environ, container auth methods
346 349 log.debug('Running PRE-AUTH for container based authentication')
347 350 pre_auth = authenticate(
348 351 '', '', environ, VCS_TYPE, registry=self.registry)
349 352 if pre_auth and pre_auth.get('username'):
350 353 username = pre_auth['username']
351 354 log.debug('PRE-AUTH got %s as username', username)
352 355
353 356 # If not authenticated by the container, running basic auth
354 357 if not username:
355 358 self.authenticate.realm = get_rhodecode_realm()
356 359
357 360 try:
358 361 result = self.authenticate(environ)
359 362 except (UserCreationError, NotAllowedToCreateUserError) as e:
360 363 log.error(e)
361 364 reason = safe_str(e)
362 365 return HTTPNotAcceptable(reason)(environ, start_response)
363 366
364 367 if isinstance(result, str):
365 368 AUTH_TYPE.update(environ, 'basic')
366 369 REMOTE_USER.update(environ, result)
367 370 username = result
368 371 else:
369 372 return result.wsgi_application(environ, start_response)
370 373
371 374 # ==============================================================
372 375 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
373 376 # ==============================================================
374 377 user = User.get_by_username(username)
375 378 if not self.valid_and_active_user(user):
376 379 return HTTPForbidden()(environ, start_response)
377 380 username = user.username
378 381 user.update_lastactivity()
379 382 meta.Session().commit()
380 383
381 384 # check user attributes for password change flag
382 385 user_obj = user
383 386 if user_obj and user_obj.username != User.DEFAULT_USER and \
384 387 user_obj.user_data.get('force_password_change'):
385 388 reason = 'password change required'
386 389 log.debug('User not allowed to authenticate, %s', reason)
387 390 return HTTPNotAcceptable(reason)(environ, start_response)
388 391
389 392 # check permissions for this repository
390 393 perm = self._check_permission(
391 394 action, user, self.repo_name, ip_addr)
392 395 if not perm:
393 396 return HTTPForbidden()(environ, start_response)
394 397
395 398 # extras are injected into UI object and later available
396 399 # in hooks executed by rhodecode
397 400 check_locking = _should_check_locking(environ.get('QUERY_STRING'))
398 401 extras = vcs_operation_context(
399 402 environ, repo_name=self.repo_name, username=username,
400 403 action=action, scm=self.SCM,
401 404 check_locking=check_locking)
402 405
403 406 # ======================================================================
404 407 # REQUEST HANDLING
405 408 # ======================================================================
406 409 str_repo_name = safe_str(self.repo_name)
407 410 repo_path = os.path.join(
408 411 safe_str(self.basepath), safe_str(self.vcs_repo_name))
409 412 log.debug('Repository path is %s', repo_path)
410 413
411 414 fix_PATH()
412 415
413 416 log.info(
414 417 '%s action on %s repo "%s" by "%s" from %s',
415 418 action, self.SCM, str_repo_name, safe_str(username), ip_addr)
416 419
417 420 return self._generate_vcs_response(
418 421 environ, start_response, repo_path, self.url_repo_name, extras, action)
419 422
420 423 @initialize_generator
421 424 def _generate_vcs_response(
422 425 self, environ, start_response, repo_path, repo_name, extras,
423 426 action):
424 427 """
425 428 Returns a generator for the response content.
426 429
427 430 This method is implemented as a generator, so that it can trigger
428 431 the cache validation after all content sent back to the client. It
429 432 also handles the locking exceptions which will be triggered when
430 433 the first chunk is produced by the underlying WSGI application.
431 434 """
432 435 callback_daemon, extras = self._prepare_callback_daemon(extras)
433 436 config = self._create_config(extras, self.acl_repo_name)
434 437 log.debug('HOOKS extras is %s', extras)
435 438 app = self._create_wsgi_app(repo_path, repo_name, config)
436 439
437 440 try:
438 441 with callback_daemon:
439 442 try:
440 443 response = app(environ, start_response)
441 444 finally:
442 445 # This statement works together with the decorator
443 446 # "initialize_generator" above. The decorator ensures that
444 447 # we hit the first yield statement before the generator is
445 448 # returned back to the WSGI server. This is needed to
446 449 # ensure that the call to "app" above triggers the
447 450 # needed callback to "start_response" before the
448 451 # generator is actually used.
449 452 yield "__init__"
450 453
451 454 for chunk in response:
452 455 yield chunk
453 456 except Exception as exc:
454 457 # TODO: johbo: Improve "translating" back the exception.
455 458 if getattr(exc, '_vcs_kind', None) == 'repo_locked':
456 459 exc = HTTPLockedRC(*exc.args)
457 460 _code = rhodecode.CONFIG.get('lock_ret_code')
458 461 log.debug('Repository LOCKED ret code %s!', (_code,))
459 462 elif getattr(exc, '_vcs_kind', None) == 'requirement':
460 463 log.debug(
461 464 'Repository requires features unknown to this Mercurial')
462 465 exc = HTTPRequirementError(*exc.args)
463 466 else:
464 467 raise
465 468
466 469 for chunk in exc(environ, start_response):
467 470 yield chunk
468 471 finally:
469 472 # invalidate cache on push
470 473 try:
471 474 if action == 'push':
472 475 self._invalidate_cache(repo_name)
473 476 finally:
474 477 meta.Session.remove()
475 478
476 479 def _get_repository_name(self, environ):
477 480 """Get repository name out of the environmnent
478 481
479 482 :param environ: WSGI environment
480 483 """
481 484 raise NotImplementedError()
482 485
483 486 def _get_action(self, environ):
484 487 """Map request commands into a pull or push command.
485 488
486 489 :param environ: WSGI environment
487 490 """
488 491 raise NotImplementedError()
489 492
490 493 def _create_wsgi_app(self, repo_path, repo_name, config):
491 494 """Return the WSGI app that will finally handle the request."""
492 495 raise NotImplementedError()
493 496
494 497 def _create_config(self, extras, repo_name):
495 498 """Create a Pyro safe config representation."""
496 499 raise NotImplementedError()
497 500
498 501 def _prepare_callback_daemon(self, extras):
499 502 return prepare_callback_daemon(
500 503 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
501 504 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
502 505
503 506
504 507 def _should_check_locking(query_string):
505 508 # this is kind of hacky, but due to how mercurial handles client-server
506 509 # server see all operation on commit; bookmarks, phases and
507 510 # obsolescence marker in different transaction, we don't want to check
508 511 # locking on those
509 512 return query_string not in ['cmd=listkeys']
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
General Comments 0
You need to be logged in to leave comments. Login now