# Copyright (C) 2011-2024 RhodeCode GmbH # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License, version 3 # (only), as published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # # This program is dual-licensed. If you wish to learn more about the # RhodeCode Enterprise Edition, including its added features, Support services, # and proprietary license terms, please see https://rhodecode.com/licenses/ import logging from rhodecode.api import jsonrpc_method, JSONRPCError, JSONRPCValidationError from rhodecode.api.utils import ( has_superadmin_permission, Optional, OAttr, get_repo_or_error, get_pull_request_or_error, get_commit_or_error, get_user_or_error, validate_repo_permissions, resolve_ref_or_error, validate_set_owner_permissions, ) from rhodecode.lib import channelstream from rhodecode.lib.auth import HasRepoPermissionAnyApi from rhodecode.lib.base import vcs_operation_context from rhodecode.lib.utils2 import str2bool from rhodecode.lib.vcs.backends.base import unicode_to_reference from rhodecode.model.changeset_status import ChangesetStatusModel from rhodecode.model.comment import CommentsModel from rhodecode.model.db import ( Session, ChangesetStatus, ChangesetComment, PullRequest, PullRequestReviewers, false, true, ) from rhodecode.model.pull_request import PullRequestModel, MergeCheck from rhodecode.model.settings import SettingsModel from rhodecode.model.validation_schema import Invalid from rhodecode.model.validation_schema.schemas.reviewer_schema import ReviewerListSchema log = logging.getLogger(__name__) @jsonrpc_method() def get_pull_request(request, apiuser, pullrequestid, repoid=Optional(None), merge_state=Optional(False)): """ Get a pull request based on the given ID. :param apiuser: This is filled automatically from the |authtoken|. :type apiuser: AuthUser :param repoid: Optional, repository name or repository ID from where the pull request was opened. :type repoid: str or int :param pullrequestid: ID of the requested pull request. :type pullrequestid: int :param merge_state: Optional calculate merge state for each repository. This could result in longer time to fetch the data :type merge_state: bool Example output: .. code-block:: bash "id": , "result": { "pull_request_id": "", "url": "", "title": "", "description": "<description>", "status" : "<status>", "created_on": "<date_time_created>", "updated_on": "<date_time_updated>", "versions": "<number_or_versions_of_pr>", "commit_ids": [ ... "<commit_id>", "<commit_id>", ... ], "review_status": "<review_status>", "mergeable": { "status": "<bool>", "message": "<message>", }, "source": { "clone_url": "<clone_url>", "repository": "<repository_name>", "reference": { "name": "<name>", "type": "<type>", "commit_id": "<commit_id>", } }, "target": { "clone_url": "<clone_url>", "repository": "<repository_name>", "reference": { "name": "<name>", "type": "<type>", "commit_id": "<commit_id>", } }, "merge": { "clone_url": "<clone_url>", "reference": { "name": "<name>", "type": "<type>", "commit_id": "<commit_id>", } }, "author": <user_obj>, "reviewers": [ ... { "user": "<user_obj>", "review_status": "<review_status>", } ... ] }, "error": null """ pull_request = get_pull_request_or_error(pullrequestid) if Optional.extract(repoid): repo = get_repo_or_error(repoid) else: repo = pull_request.target_repo if not PullRequestModel().check_user_read(pull_request, apiuser, api=True): raise JSONRPCError(f"repository `{repoid}` or pull request `{pullrequestid}` does not exist") # NOTE(marcink): only calculate and return merge state if the pr state is 'created' # otherwise we can lock the repo on calculation of merge state while update/merge # is happening. pr_created = pull_request.pull_request_state == pull_request.STATE_CREATED merge_state = Optional.extract(merge_state, binary=True) and pr_created data = pull_request.get_api_data(with_merge_state=merge_state) return data @jsonrpc_method() def get_pull_requests(request, apiuser, repoid, status=Optional("new"), merge_state=Optional(False)): """ Get all pull requests from the repository specified in `repoid`. :param apiuser: This is filled automatically from the |authtoken|. :type apiuser: AuthUser :param repoid: Optional repository name or repository ID. :type repoid: str or int :param status: Only return pull requests with the specified status. Valid options are. * ``new`` (default) * ``open`` * ``closed`` :type status: str :param merge_state: Optional calculate merge state for each repository. This could result in longer time to fetch the data :type merge_state: bool Example output: .. code-block:: bash "id": <id_given_in_input>, "result": [ ... { "pull_request_id": "<pull_request_id>", "url": "<url>", "title" : "<title>", "description": "<description>", "status": "<status>", "created_on": "<date_time_created>", "updated_on": "<date_time_updated>", "commit_ids": [ ... "<commit_id>", "<commit_id>", ... ], "review_status": "<review_status>", "mergeable": { "status": "<bool>", "message: "<message>", }, "source": { "clone_url": "<clone_url>", "reference": { "name": "<name>", "type": "<type>", "commit_id": "<commit_id>", } }, "target": { "clone_url": "<clone_url>", "reference": { "name": "<name>", "type": "<type>", "commit_id": "<commit_id>", } }, "merge": { "clone_url": "<clone_url>", "reference": { "name": "<name>", "type": "<type>", "commit_id": "<commit_id>", } }, "author": <user_obj>, "reviewers": [ ... { "user": "<user_obj>", "review_status": "<review_status>", } ... ] } ... ], "error": null """ repo = get_repo_or_error(repoid) if not has_superadmin_permission(apiuser): _perms = ( "repository.admin", "repository.write", "repository.read", ) validate_repo_permissions(apiuser, repoid, repo, _perms) status = Optional.extract(status) merge_state = Optional.extract(merge_state, binary=True) pull_requests = PullRequestModel().get_all(repo, statuses=[status], order_by="id", order_dir="desc") data = [pr.get_api_data(with_merge_state=merge_state) for pr in pull_requests] return data @jsonrpc_method() def merge_pull_request(request, apiuser, pullrequestid, repoid=Optional(None), userid=Optional(OAttr("apiuser"))): """ Merge the pull request specified by `pullrequestid` into its target repository. :param apiuser: This is filled automatically from the |authtoken|. :type apiuser: AuthUser :param repoid: Optional, repository name or repository ID of the target repository to which the |pr| is to be merged. :type repoid: str or int :param pullrequestid: ID of the pull request which shall be merged. :type pullrequestid: int :param userid: Merge the pull request as this user. :type userid: Optional(str or int) Example output: .. code-block:: bash "id": <id_given_in_input>, "result": { "executed": "<bool>", "failure_reason": "<int>", "merge_status_message": "<str>", "merge_commit_id": "<merge_commit_id>", "possible": "<bool>", "merge_ref": { "commit_id": "<commit_id>", "type": "<type>", "name": "<name>" } }, "error": null """ pull_request = get_pull_request_or_error(pullrequestid) if Optional.extract(repoid): repo = get_repo_or_error(repoid) else: repo = pull_request.target_repo auth_user = apiuser if not isinstance(userid, Optional): # super-admin can impersonate other users if has_superadmin_permission(apiuser): apiuser = get_user_or_error(userid) auth_user = apiuser.AuthUser() else: raise JSONRPCError("Provided userid is not the same the user calling this function") if pull_request.pull_request_state != PullRequest.STATE_CREATED: raise JSONRPCError( f"Operation forbidden because pull request is in state {pull_request.pull_request_state}, only state {PullRequest.STATE_CREATED} is allowed." ) with pull_request.set_state(PullRequest.STATE_UPDATING): check = MergeCheck.validate(pull_request, auth_user=auth_user, translator=request.translate) merge_possible = not check.failed if not merge_possible: error_messages = [] for err_type, error_msg in check.errors: error_msg = request.translate(error_msg) error_messages.append(error_msg) reasons = ",".join(error_messages) raise JSONRPCError(f"merge not possible for following reasons: {reasons}") target_repo = pull_request.target_repo extras = vcs_operation_context( request.environ, repo_name=target_repo.repo_name, username=auth_user.username, action="push", scm=target_repo.repo_type, ) with pull_request.set_state(PullRequest.STATE_UPDATING): merge_response = PullRequestModel().merge_repo(pull_request, apiuser, extras=extras) if merge_response.executed: PullRequestModel().close_pull_request(pull_request.pull_request_id, auth_user) Session().commit() # In previous versions the merge response directly contained the merge # commit id. It is now contained in the merge reference object. To be # backwards compatible we have to extract it again. merge_response = merge_response.asdict() merge_response["merge_commit_id"] = merge_response["merge_ref"].commit_id return merge_response @jsonrpc_method() def get_pull_request_comments(request, apiuser, pullrequestid, repoid=Optional(None)): """ Get all comments of pull request specified with the `pullrequestid` :param apiuser: This is filled automatically from the |authtoken|. :type apiuser: AuthUser :param repoid: Optional repository name or repository ID. :type repoid: str or int :param pullrequestid: The pull request ID. :type pullrequestid: int Example output: .. code-block:: bash id : <id_given_in_input> result : [ { "comment_author": { "active": true, "full_name_or_username": "Tom Gore", "username": "admin" }, "comment_created_on": "2017-01-02T18:43:45.533", "comment_f_path": null, "comment_id": 25, "comment_lineno": null, "comment_status": { "status": "under_review", "status_lbl": "Under Review" }, "comment_text": "Example text", "comment_type": null, "comment_last_version: 0, "pull_request_version": null, "comment_commit_id": None, "comment_pull_request_id": <pull_request_id> } ], error : null """ pull_request = get_pull_request_or_error(pullrequestid) if Optional.extract(repoid): repo = get_repo_or_error(repoid) else: repo = pull_request.target_repo if not PullRequestModel().check_user_read(pull_request, apiuser, api=True): raise JSONRPCError(f"repository `{repoid}` or pull request `{pullrequestid}` does not exist") (pull_request_latest, pull_request_at_ver, pull_request_display_obj, at_version) = ( PullRequestModel().get_pr_version(pull_request.pull_request_id, version=None) ) versions = pull_request_display_obj.versions() ver_map = {ver.pull_request_version_id: cnt for cnt, ver in enumerate(versions, 1)} # GENERAL COMMENTS with versions # q = CommentsModel()._all_general_comments_of_pull_request(pull_request) q = q.order_by(ChangesetComment.comment_id.asc()) q = q.filter(ChangesetComment.draft == false()) general_comments = q.all() # INLINE COMMENTS with versions # q = CommentsModel()._all_inline_comments_of_pull_request(pull_request) q = q.order_by(ChangesetComment.comment_id.asc()) q = q.filter(ChangesetComment.draft == false()) inline_comments = q.all() # INLINE DRAFT COMMENTS with versions # q = CommentsModel()._all_inline_comments_of_pull_request(pull_request) q = q.order_by(ChangesetComment.comment_id.asc()) q = q.filter(ChangesetComment.user_id == apiuser.user_id) q = q.filter(ChangesetComment.draft == true()) inline_draft_comments = q.all() log.error(q) data = [] for comment in inline_draft_comments + inline_comments + general_comments: full_data = comment.get_api_data() pr_version_id = None if comment.pull_request_version_id: pr_version_id = f"v{ver_map[comment.pull_request_version_id]}" # sanitize some entries full_data["pull_request_version"] = pr_version_id full_data["comment_author"] = { "username": full_data["comment_author"].username, "full_name_or_username": full_data["comment_author"].full_name_or_username, "active": full_data["comment_author"].active, } if full_data["comment_status"]: full_data["comment_status"] = { "status": full_data["comment_status"][0].status, "status_lbl": full_data["comment_status"][0].status_lbl, } else: full_data["comment_status"] = {} data.append(full_data) return data @jsonrpc_method() def comment_pull_request( request, apiuser, pullrequestid, repoid=Optional(None), message=Optional(None), commit_id=Optional(None), status=Optional(None), comment_type=Optional(ChangesetComment.COMMENT_TYPE_NOTE), resolves_comment_id=Optional(None), extra_recipients=Optional([]), userid=Optional(OAttr("apiuser")), send_email=Optional(True), ): """ Comment on the pull request specified with the `pullrequestid`, in the |repo| specified by the `repoid`, and optionally change the review status. :param apiuser: This is filled automatically from the |authtoken|. :type apiuser: AuthUser :param repoid: Optional repository name or repository ID. :type repoid: str or int :param pullrequestid: The pull request ID. :type pullrequestid: int :param commit_id: Specify the commit_id for which to set a comment. If given commit_id is different than latest in the PR status change won't be performed. :type commit_id: str :param message: The text content of the comment. :type message: str :param status: (**Optional**) Set the approval status of the pull request. One of: 'not_reviewed', 'approved', 'rejected', 'under_review' :type status: str :param comment_type: Comment type, one of: 'note', 'todo' :type comment_type: Optional(str), default: 'note' :param resolves_comment_id: id of comment which this one will resolve :type resolves_comment_id: Optional(int) :param extra_recipients: list of user ids or usernames to add notifications for this comment. Acts like a CC for notification :type extra_recipients: Optional(list) :param userid: Comment on the pull request as this user :type userid: Optional(str or int) :param send_email: Define if this comment should also send email notification :type send_email: Optional(bool) Example output: .. code-block:: bash id : <id_given_in_input> result : { "pull_request_id": "<Integer>", "comment_id": "<Integer>", "status": {"given": <given_status>, "was_changed": <bool status_was_actually_changed> }, }, error : null """ _ = request.translate pull_request = get_pull_request_or_error(pullrequestid) if Optional.extract(repoid): repo = get_repo_or_error(repoid) else: repo = pull_request.target_repo db_repo_name = repo.repo_name auth_user = apiuser if isinstance(userid, Optional): userid = apiuser.user_id else: # super-admin can impersonate other users if has_superadmin_permission(apiuser): apiuser = get_user_or_error(userid) userid = apiuser.user_id auth_user = apiuser.AuthUser() else: raise JSONRPCError("Provided userid is not the same the user calling this function") if pull_request.is_closed(): raise JSONRPCError(f"pull request `{pullrequestid}` comment failed, pull request is closed") if not PullRequestModel().check_user_read(pull_request, apiuser, api=True): raise JSONRPCError(f"repository `{repoid}` does not exist") user = get_user_or_error(userid) message = Optional.extract(message) status = Optional.extract(status) commit_id = Optional.extract(commit_id) comment_type = Optional.extract(comment_type) resolves_comment_id = Optional.extract(resolves_comment_id) extra_recipients = Optional.extract(extra_recipients) send_email = Optional.extract(send_email, binary=True) if not message and not status: raise JSONRPCError("Both message and status parameters are missing. At least one is required.") if status and status not in (st[0] for st in ChangesetStatus.STATUSES): raise JSONRPCError(f"Unknown comment status: `{status}`") if commit_id and commit_id not in pull_request.revisions: raise JSONRPCError(f"Invalid commit_id `{commit_id}` for this pull request.") allowed_to_change_status = PullRequestModel().check_user_change_status(pull_request, apiuser) # if commit_id is passed re-validated if user is allowed to change status # based on the latest commit_id from the PR if commit_id: commit_idx = pull_request.revisions.index(commit_id) if commit_idx != 0: log.warning("Resetting allowed_to_change_status = False because commit is NOT the latest in pull-request") allowed_to_change_status = False if resolves_comment_id: comment = ChangesetComment.get(resolves_comment_id) if not comment: raise JSONRPCError(f"Invalid resolves_comment_id `{resolves_comment_id}` for this pull request.") if comment.comment_type != ChangesetComment.COMMENT_TYPE_TODO: raise JSONRPCError(f"Comment `{resolves_comment_id}` is wrong type for setting status to resolved.") text = message status_label = ChangesetStatus.get_status_lbl(status) if status and allowed_to_change_status: st_message = f"Status change > {status_label}" text = message or st_message rc_config = SettingsModel().get_all_settings() renderer = rc_config.get("rhodecode_markup_renderer", "rst") status_change = status and allowed_to_change_status comment = CommentsModel().create( text=text, repo=pull_request.target_repo.repo_id, user=user, pull_request=pull_request.pull_request_id, f_path=None, line_no=None, status_change=(status_label if status_change else None), status_change_type=(status if status_change else None), closing_pr=False, renderer=renderer, comment_type=comment_type, resolves_comment_id=resolves_comment_id, auth_user=auth_user, extra_recipients=extra_recipients, send_email=send_email, ) is_inline = comment.is_inline if allowed_to_change_status and status: old_calculated_status = pull_request.calculated_review_status() ChangesetStatusModel().set_status( pull_request.target_repo.repo_id, status, apiuser.user_id, comment, pull_request=pull_request.pull_request_id, ) Session().flush() Session().commit() PullRequestModel().trigger_pull_request_hook(pull_request, apiuser, "comment", data={"comment": comment}) if allowed_to_change_status and status: # we now calculate the status of pull request, and based on that # calculation we set the commits status calculated_status = pull_request.calculated_review_status() if old_calculated_status != calculated_status: PullRequestModel().trigger_pull_request_hook( pull_request, apiuser, "review_status_change", data={"status": calculated_status} ) data = { "pull_request_id": pull_request.pull_request_id, "comment_id": comment.comment_id if comment else None, "status": {"given": status, "was_changed": status_change}, } comment_broadcast_channel = channelstream.comment_channel(db_repo_name, pull_request_obj=pull_request) comment_data = data comment_type = "inline" if is_inline else "general" channelstream.comment_channelstream_push( request, comment_broadcast_channel, apiuser, _("posted a new {} comment").format(comment_type), comment_data=comment_data, ) return data def _reviewers_validation(obj_list): schema = ReviewerListSchema() try: reviewer_objects = schema.deserialize(obj_list) except Invalid as err: raise JSONRPCValidationError(colander_exc=err) # validate users for reviewer_object in reviewer_objects: user = get_user_or_error(reviewer_object["username"]) reviewer_object["user_id"] = user.user_id return reviewer_objects @jsonrpc_method() def create_pull_request( request, apiuser, source_repo, target_repo, source_ref, target_ref, owner=Optional(OAttr("apiuser")), title=Optional(""), description=Optional(""), description_renderer=Optional(""), reviewers=Optional(None), observers=Optional(None), ): """ Creates a new pull request. Accepts refs in the following formats: * branch:<branch_name>:<sha> * branch:<branch_name> * bookmark:<bookmark_name>:<sha> (Mercurial only) * bookmark:<bookmark_name> (Mercurial only) :param apiuser: This is filled automatically from the |authtoken|. :type apiuser: AuthUser :param source_repo: Set the source repository name. :type source_repo: str :param target_repo: Set the target repository name. :type target_repo: str :param source_ref: Set the source ref name. :type source_ref: str :param target_ref: Set the target ref name. :type target_ref: str :param owner: user_id or username :type owner: Optional(str) :param title: Optionally Set the pull request title, it's generated otherwise :type title: str :param description: Set the pull request description. :type description: Optional(str) :type description_renderer: Optional(str) :param description_renderer: Set pull request renderer for the description. It should be 'rst', 'markdown' or 'plain'. If not give default system renderer will be used :param reviewers: Set the new pull request reviewers list. Reviewer defined by review rules will be added automatically to the defined list. :type reviewers: Optional(list) Accepts username strings or objects of the format: [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}] :param observers: Set the new pull request observers list. Reviewer defined by review rules will be added automatically to the defined list. This feature is only available in RhodeCode EE :type observers: Optional(list) Accepts username strings or objects of the format: [{'username': 'nick', 'reasons': ['original author']}] """ source_db_repo = get_repo_or_error(source_repo) target_db_repo = get_repo_or_error(target_repo) if not has_superadmin_permission(apiuser): _perms = ( "repository.admin", "repository.write", "repository.read", ) validate_repo_permissions(apiuser, source_repo, source_db_repo, _perms) owner = validate_set_owner_permissions(apiuser, owner) full_source_ref = resolve_ref_or_error(source_ref, source_db_repo) full_target_ref = resolve_ref_or_error(target_ref, target_db_repo) get_commit_or_error(full_source_ref, source_db_repo) get_commit_or_error(full_target_ref, target_db_repo) reviewer_objects = Optional.extract(reviewers) or [] observer_objects = Optional.extract(observers) or [] # serialize and validate passed in given reviewers if reviewer_objects: reviewer_objects = _reviewers_validation(reviewer_objects) if observer_objects: observer_objects = _reviewers_validation(reviewer_objects) get_default_reviewers_data, validate_default_reviewers, validate_observers = ( PullRequestModel().get_reviewer_functions() ) source_ref_obj = unicode_to_reference(full_source_ref) target_ref_obj = unicode_to_reference(full_target_ref) # recalculate reviewers logic, to make sure we can validate this default_reviewers_data = get_default_reviewers_data( owner, source_db_repo, source_ref_obj, target_db_repo, target_ref_obj, ) # now MERGE our given with the calculated from the default rules just_reviewers = [x for x in default_reviewers_data["reviewers"] if x["role"] == PullRequestReviewers.ROLE_REVIEWER] reviewer_objects = just_reviewers + reviewer_objects try: reviewers = validate_default_reviewers(reviewer_objects, default_reviewers_data) except ValueError as e: raise JSONRPCError(f"Reviewers Validation: {e}") # now MERGE our given with the calculated from the default rules just_observers = [x for x in default_reviewers_data["reviewers"] if x["role"] == PullRequestReviewers.ROLE_OBSERVER] observer_objects = just_observers + observer_objects try: observers = validate_observers(observer_objects, default_reviewers_data) except ValueError as e: raise JSONRPCError(f"Observer Validation: {e}") title = Optional.extract(title) if not title: title_source_ref = source_ref_obj.name title = PullRequestModel().generate_pullrequest_title( source=source_repo, source_ref=title_source_ref, target=target_repo ) diff_info = default_reviewers_data["diff_info"] common_ancestor_id = diff_info["ancestor"] # NOTE(marcink): reversed is consistent with how we open it in the WEB interface commits = [commit["commit_id"] for commit in reversed(diff_info["commits"])] if not common_ancestor_id: raise JSONRPCError("no common ancestor found between specified references") if not commits: raise JSONRPCError("no commits found for merge between specified references") # recalculate target ref based on ancestor full_target_ref = ":".join((target_ref_obj.type, target_ref_obj.name, common_ancestor_id)) # fetch renderer, if set fallback to plain in case of PR rc_config = SettingsModel().get_all_settings() default_system_renderer = rc_config.get("rhodecode_markup_renderer", "plain") description = Optional.extract(description) description_renderer = Optional.extract(description_renderer) or default_system_renderer pull_request = PullRequestModel().create( created_by=owner.user_id, source_repo=source_repo, source_ref=full_source_ref, target_repo=target_repo, target_ref=full_target_ref, common_ancestor_id=common_ancestor_id, revisions=commits, reviewers=reviewers, observers=observers, title=title, description=description, description_renderer=description_renderer, reviewer_data=default_reviewers_data, auth_user=apiuser, ) Session().commit() data = { "msg": f"Created new pull request `{title}`", "pull_request_id": pull_request.pull_request_id, } return data @jsonrpc_method() def update_pull_request( request, apiuser, pullrequestid, repoid=Optional(None), title=Optional(""), description=Optional(""), description_renderer=Optional(""), reviewers=Optional(None), observers=Optional(None), update_commits=Optional(None), ): """ Updates a pull request. :param apiuser: This is filled automatically from the |authtoken|. :type apiuser: AuthUser :param repoid: Optional repository name or repository ID. :type repoid: str or int :param pullrequestid: The pull request ID. :type pullrequestid: int :param title: Set the pull request title. :type title: str :param description: Update pull request description. :type description: Optional(str) :type description_renderer: Optional(str) :param description_renderer: Update pull request renderer for the description. It should be 'rst', 'markdown' or 'plain' :param reviewers: Update pull request reviewers list with new value. :type reviewers: Optional(list) Accepts username strings or objects of the format: [{'username': 'nick', 'reasons': ['original author'], 'mandatory': <bool>}] :param observers: Update pull request observers list with new value. :type observers: Optional(list) Accepts username strings or objects of the format: [{'username': 'nick', 'reasons': ['should be aware about this PR']}] :param update_commits: Trigger update of commits for this pull request :type: update_commits: Optional(bool) Example output: .. code-block:: bash id : <id_given_in_input> result : { "msg": "Updated pull request `63`", "pull_request": <pull_request_object>, "updated_reviewers": { "added": [ "username" ], "removed": [] }, "updated_observers": { "added": [ "username" ], "removed": [] }, "updated_commits": { "added": [ "<sha1_hash>" ], "common": [ "<sha1_hash>", "<sha1_hash>", ], "removed": [] } } error : null """ pull_request = get_pull_request_or_error(pullrequestid) if Optional.extract(repoid): repo = get_repo_or_error(repoid) else: repo = pull_request.target_repo if not PullRequestModel().check_user_update(pull_request, apiuser, api=True): raise JSONRPCError(f"pull request `{pullrequestid}` update failed, no permission to update.") if pull_request.is_closed(): raise JSONRPCError(f"pull request `{pullrequestid}` update failed, pull request is closed") reviewer_objects = Optional.extract(reviewers) or [] observer_objects = Optional.extract(observers) or [] title = Optional.extract(title) description = Optional.extract(description) description_renderer = Optional.extract(description_renderer) # Update title/description title_changed = False if title or description: PullRequestModel().edit( pull_request, title or pull_request.title, description or pull_request.description, description_renderer or pull_request.description_renderer, apiuser, ) Session().commit() title_changed = True commit_changes = {"added": [], "common": [], "removed": []} # Update commits commits_changed = False if str2bool(Optional.extract(update_commits)): if pull_request.pull_request_state != PullRequest.STATE_CREATED: raise JSONRPCError( f"Operation forbidden because pull request is in state {pull_request.pull_request_state}, only state {PullRequest.STATE_CREATED} is allowed." ) with pull_request.set_state(PullRequest.STATE_UPDATING): if PullRequestModel().has_valid_update_type(pull_request): db_user = apiuser.get_instance() update_response = PullRequestModel().update_commits(pull_request, db_user) commit_changes = update_response.changes or commit_changes Session().commit() commits_changed = True # Update reviewers # serialize and validate passed in given reviewers if reviewer_objects: reviewer_objects = _reviewers_validation(reviewer_objects) if observer_objects: observer_objects = _reviewers_validation(reviewer_objects) # re-use stored rules default_reviewers_data = pull_request.reviewer_data __, validate_default_reviewers, validate_observers = PullRequestModel().get_reviewer_functions() if reviewer_objects: try: reviewers = validate_default_reviewers(reviewer_objects, default_reviewers_data) except ValueError as e: raise JSONRPCError(f"Reviewers Validation: {e}") else: reviewers = [] if observer_objects: try: observers = validate_default_reviewers(reviewer_objects, default_reviewers_data) except ValueError as e: raise JSONRPCError(f"Observer Validation: {e}") else: observers = [] reviewers_changed = False reviewers_changes = {"added": [], "removed": []} if reviewers: old_calculated_status = pull_request.calculated_review_status() added_reviewers, removed_reviewers = PullRequestModel().update_reviewers( pull_request, reviewers, apiuser.get_instance() ) reviewers_changes["added"] = sorted([get_user_or_error(n).username for n in added_reviewers]) reviewers_changes["removed"] = sorted([get_user_or_error(n).username for n in removed_reviewers]) Session().commit() # trigger status changed if change in reviewers changes the status calculated_status = pull_request.calculated_review_status() if old_calculated_status != calculated_status: PullRequestModel().trigger_pull_request_hook( pull_request, apiuser, "review_status_change", data={"status": calculated_status} ) reviewers_changed = True observers_changed = False observers_changes = {"added": [], "removed": []} if observers: added_observers, removed_observers = PullRequestModel().update_observers( pull_request, observers, apiuser.get_instance() ) observers_changes["added"] = sorted([get_user_or_error(n).username for n in added_observers]) observers_changes["removed"] = sorted([get_user_or_error(n).username for n in removed_observers]) Session().commit() reviewers_changed = True # push changed to channelstream if commits_changed or reviewers_changed or observers_changed: pr_broadcast_channel = channelstream.pr_channel(pull_request) msg = "Pull request was updated." channelstream.pr_update_channelstream_push(request, pr_broadcast_channel, apiuser, msg) data = { "msg": f"Updated pull request `{pull_request.pull_request_id}`", "pull_request": pull_request.get_api_data(), "updated_commits": commit_changes, "updated_reviewers": reviewers_changes, "updated_observers": observers_changes, } return data @jsonrpc_method() def close_pull_request( request, apiuser, pullrequestid, repoid=Optional(None), userid=Optional(OAttr("apiuser")), message=Optional("") ): """ Close the pull request specified by `pullrequestid`. :param apiuser: This is filled automatically from the |authtoken|. :type apiuser: AuthUser :param repoid: Repository name or repository ID to which the pull request belongs. :type repoid: str or int :param pullrequestid: ID of the pull request to be closed. :type pullrequestid: int :param userid: Close the pull request as this user. :type userid: Optional(str or int) :param message: Optional message to close the Pull Request with. If not specified it will be generated automatically. :type message: Optional(str) Example output: .. code-block:: bash "id": <id_given_in_input>, "result": { "pull_request_id": "<int>", "close_status": "<str:status_lbl>, "closed": "<bool>" }, "error": null """ _ = request.translate pull_request = get_pull_request_or_error(pullrequestid) if Optional.extract(repoid): repo = get_repo_or_error(repoid) else: repo = pull_request.target_repo auth_user = apiuser if not isinstance(userid, Optional): if has_superadmin_permission(apiuser): # super-admin can impersonate other users auth_user = get_user_or_error(userid) else: raise JSONRPCError("Provided userid is not the same the user calling this function") if pull_request.is_closed(): raise JSONRPCError(f"pull request `{pullrequestid}` is already closed") # only owner or admin or person with write permissions allowed_to_close = PullRequestModel().check_user_update(pull_request, apiuser, api=True) if not allowed_to_close: raise JSONRPCError(f"pull request `{pullrequestid}` close failed, no permission to close.") # message we're using to close the PR, else it's automatically generated message = Optional.extract(message) # finally close the PR, with proper message comment comment, status = PullRequestModel().close_pull_request_with_comment( pull_request, apiuser, repo, message=message, auth_user=auth_user ) status_lbl = ChangesetStatus.get_status_lbl(status) Session().commit() data = { "pull_request_id": pull_request.pull_request_id, "close_status": status_lbl, "closed": True, } return data