Skip to content

feat: add support for deployment approval endpoint #2254

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/gl_objects/deployments.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
===========================================

Expand Down
4 changes: 4 additions & 0 deletions gitlab/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
52 changes: 51 additions & 1 deletion gitlab/v4/objects/deployments.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand Down
134 changes: 132 additions & 2 deletions tests/unit/objects/test_deployments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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",
Expand All @@ -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,
},
}