diff --git a/docs/gl_objects/deployments.rst b/docs/gl_objects/deployments.rst index ae101033d..9c810ceb6 100644 --- a/docs/gl_objects/deployments.rst +++ b/docs/gl_objects/deployments.rst @@ -40,6 +40,18 @@ Update a deployment:: deployment.status = "failed" deployment.save() +Approve a deployment:: + + deployment = project.deployments.get(42) + # `status` must be either "approved" or "rejected". + deployment.approval(status="approved") + +Reject a deployment:: + + deployment = project.deployments.get(42) + # Using the optional `comment` and `represented_as` arguments + deployment.approval(status="rejected", comment="Fails CI", represented_as="security") + Merge requests associated with a deployment =========================================== diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py index 01439e483..633de5ba7 100644 --- a/gitlab/exceptions.py +++ b/gitlab/exceptions.py @@ -301,6 +301,10 @@ class GitlabUserRejectError(GitlabOperationError): pass +class GitlabDeploymentApprovalError(GitlabOperationError): + pass + + # For an explanation of how these type-hints work see: # https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators # diff --git a/gitlab/v4/objects/deployments.py b/gitlab/v4/objects/deployments.py index a431603be..145273b52 100644 --- a/gitlab/v4/objects/deployments.py +++ b/gitlab/v4/objects/deployments.py @@ -1,5 +1,11 @@ -from typing import Any, cast, Union +""" +GitLab API: +https://docs.gitlab.com/ee/api/deployments.html +""" +from typing import Any, cast, Dict, Optional, TYPE_CHECKING, Union +from gitlab import cli +from gitlab import exceptions as exc from gitlab.base import RESTManager, RESTObject from gitlab.mixins import CreateMixin, RetrieveMixin, SaveMixin, UpdateMixin from gitlab.types import RequiredOptional @@ -15,6 +21,50 @@ class ProjectDeployment(SaveMixin, RESTObject): mergerequests: ProjectDeploymentMergeRequestManager + @cli.register_custom_action( + "ProjectDeployment", + mandatory=("status",), + optional=("comment", "represented_as"), + ) + @exc.on_http_error(exc.GitlabDeploymentApprovalError) + def approval( + self, + status: str, + comment: Optional[str] = None, + represented_as: Optional[str] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + """Approve or reject a blocked deployment. + + Args: + status: Either "approved" or "rejected" + comment: A comment to go with the approval + represented_as: The name of the User/Group/Role to use for the + approval, when the user belongs to multiple + approval rules. + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabMRApprovalError: If the approval failed + + Returns: + A dict containing the result. + + https://docs.gitlab.com/ee/api/deployments.html#approve-or-reject-a-blocked-deployment + """ + path = f"{self.manager.path}/{self.encoded_id}/approval" + data = {"status": status} + if comment is not None: + data["comment"] = comment + if represented_as is not None: + data["represented_as"] = represented_as + + server_data = self.manager.gitlab.http_post(path, post_data=data, **kwargs) + if TYPE_CHECKING: + assert isinstance(server_data, dict) + return server_data + class ProjectDeploymentManager(RetrieveMixin, CreateMixin, UpdateMixin, RESTManager): _path = "/projects/{project_id}/deployments" diff --git a/tests/unit/objects/test_deployments.py b/tests/unit/objects/test_deployments.py index 92e33c2ad..e7099f271 100644 --- a/tests/unit/objects/test_deployments.py +++ b/tests/unit/objects/test_deployments.py @@ -6,7 +6,25 @@ @pytest.fixture -def resp_deployment(): +def resp_deployment_get(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/deployments/42", + json=response_get_content, + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def deployment(project): + return project.deployments.get(42, lazy=True) + + +@pytest.fixture +def resp_deployment_create(): content = {"id": 42, "status": "success", "ref": "main"} with responses.RequestsMock() as rsps: @@ -31,7 +49,42 @@ def resp_deployment(): yield rsps -def test_deployment(project, resp_deployment): +@pytest.fixture +def resp_deployment_approval(): + content = { + "user": { + "id": 100, + "username": "security-user-1", + "name": "security user-1", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e130fcd3a1681f41a3de69d10841afa9?s=80&d=identicon", + "web_url": "http://localhost:3000/security-user-1", + }, + "status": "approved", + "created_at": "2022-02-24T20:22:30.097Z", + "comment": "Looks good to me", + } + + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.POST, + url="http://localhost/api/v4/projects/1/deployments/42/approval", + json=content, + content_type="application/json", + status=200, + ) + yield rsps + + +def test_deployment_get(project, resp_deployment_get): + deployment = project.deployments.get(42) + assert deployment.id == 42 + assert deployment.iid == 2 + assert deployment.status == "success" + assert deployment.ref == "main" + + +def test_deployment_create(project, resp_deployment_create): deployment = project.deployments.create( { "environment": "Test", @@ -48,3 +101,80 @@ def test_deployment(project, resp_deployment): deployment.status = "failed" deployment.save() assert deployment.status == "failed" + + +def test_deployment_approval(deployment, resp_deployment_approval) -> None: + result = deployment.approval(status="approved") + assert result["status"] == "approved" + assert result["comment"] == "Looks good to me" + + +response_get_content = { + "id": 42, + "iid": 2, + "ref": "main", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "created_at": "2016-08-11T11:32:35.444Z", + "updated_at": "2016-08-11T11:34:01.123Z", + "status": "success", + "user": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://localhost:3000/root", + }, + "environment": { + "id": 9, + "name": "production", + "external_url": "https://about.gitlab.com", + }, + "deployable": { + "id": 664, + "status": "success", + "stage": "deploy", + "name": "deploy", + "ref": "main", + "tag": False, + "coverage": None, + "created_at": "2016-08-11T11:32:24.456Z", + "started_at": None, + "finished_at": "2016-08-11T11:32:35.145Z", + "user": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://gitlab.dev/root", + "created_at": "2015-12-21T13:14:24.077Z", + "bio": None, + "location": None, + "skype": "", + "linkedin": "", + "twitter": "", + "website_url": "", + "organization": "", + }, + "commit": { + "id": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "short_id": "a91957a8", + "title": "Merge branch 'rename-readme' into 'main'\r", + "author_name": "Administrator", + "author_email": "admin@example.com", + "created_at": "2016-08-11T13:28:26.000+02:00", + "message": "Merge branch 'rename-readme' into 'main'\r\n\r\nRename README\r\n\r\n\r\n\r\nSee merge request !2", + }, + "pipeline": { + "created_at": "2016-08-11T07:43:52.143Z", + "id": 42, + "ref": "main", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "status": "success", + "updated_at": "2016-08-11T07:43:52.143Z", + "web_url": "http://gitlab.dev/root/project/pipelines/5", + }, + "runner": None, + }, +}