Repository: python-gitlab/python-gitlab

Issue:
Add support for resource milestone events
Although resource label events are supported by `python-gitlab`, it seems that [resource milestone events](https://docs.gitlab.com/ce/api/resource_milestone_events.html) are not. These are useful for tracking when resources are added and removed from milestones, for example when creating burndown charts.

It's not totally clear to me how new object types can be added to the library, but if someone is willing to offer a bit of advice, I'd be happy to have a first go at adding this feature.

## Specifications

* python-gitlab version: 2.2.0
* API version you are using (v3/v4): v4
Add support for resource milestone events
Although resource label events are supported by `python-gitlab`, it seems that [resource milestone events](https://docs.gitlab.com/ce/api/resource_milestone_events.html) are not. These are useful for tracking when resources are added and removed from milestones, for example when creating burndown charts.

It's not totally clear to me how new object types can be added to the library, but if someone is willing to offer a bit of advice, I'd be happy to have a first go at adding this feature.

## Specifications

* python-gitlab version: 2.2.0
* API version you are using (v3/v4): v4


Discussion:

Resolving patch:
diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py
--- a/gitlab/v4/objects.py
+++ b/gitlab/v4/objects.py
@@ -711,8 +711,14 @@ class ProjectDeployTokenManager(ListMixin, CreateMixin, DeleteMixin, RESTManager
     _from_parent_attrs = {"project_id": "id"}
     _obj_cls = ProjectDeployToken
     _create_attrs = (
-        ("name", "scopes",),
-        ("expires_at", "username",),
+        (
+            "name",
+            "scopes",
+        ),
+        (
+            "expires_at",
+            "username",
+        ),
     )
 
 
@@ -725,8 +731,14 @@ class GroupDeployTokenManager(ListMixin, CreateMixin, DeleteMixin, RESTManager):
     _from_parent_attrs = {"group_id": "id"}
     _obj_cls = GroupDeployToken
     _create_attrs = (
-        ("name", "scopes",),
-        ("expires_at", "username",),
+        (
+            "name",
+            "scopes",
+        ),
+        (
+            "expires_at",
+            "username",
+        ),
     )
 
 
@@ -2652,6 +2664,16 @@ class ProjectIssueResourceLabelEventManager(RetrieveMixin, RESTManager):
     _from_parent_attrs = {"project_id": "project_id", "issue_iid": "iid"}
 
 
+class ProjectIssueResourceMilestoneEvent(RESTObject):
+    pass
+
+
+class ProjectIssueResourceMilestoneEventManager(RetrieveMixin, RESTManager):
+    _path = "/projects/%(project_id)s/issues/%(issue_iid)s/resource_milestone_events"
+    _obj_cls = ProjectIssueResourceMilestoneEvent
+    _from_parent_attrs = {"project_id": "project_id", "issue_iid": "iid"}
+
+
 class ProjectIssue(
     UserAgentDetailMixin,
     SubscribableMixin,
@@ -2670,6 +2692,7 @@ class ProjectIssue(
         ("links", "ProjectIssueLinkManager"),
         ("notes", "ProjectIssueNoteManager"),
         ("resourcelabelevents", "ProjectIssueResourceLabelEventManager"),
+        ("resourcemilestoneevents", "ProjectIssueResourceMilestoneEventManager"),
     )
 
     @cli.register_custom_action("ProjectIssue", ("to_project_id",))
@@ -3063,6 +3086,18 @@ class ProjectMergeRequestResourceLabelEventManager(RetrieveMixin, RESTManager):
     _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"}
 
 
+class ProjectMergeRequestResourceMilestoneEvent(RESTObject):
+    pass
+
+
+class ProjectMergeRequestResourceMilestoneEventManager(RetrieveMixin, RESTManager):
+    _path = (
+        "/projects/%(project_id)s/merge_requests/%(mr_iid)s/resource_milestone_events"
+    )
+    _obj_cls = ProjectMergeRequestResourceMilestoneEvent
+    _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"}
+
+
 class ProjectMergeRequest(
     SubscribableMixin,
     TodoMixin,
@@ -3081,6 +3116,7 @@ class ProjectMergeRequest(
         ("discussions", "ProjectMergeRequestDiscussionManager"),
         ("notes", "ProjectMergeRequestNoteManager"),
         ("resourcelabelevents", "ProjectMergeRequestResourceLabelEventManager"),
+        ("resourcemilestoneevents", "ProjectMergeRequestResourceMilestoneEventManager"),
     )
 
     @cli.register_custom_action("ProjectMergeRequest")
@@ -4181,7 +4217,11 @@ class ProjectServiceManager(GetMixin, UpdateMixin, DeleteMixin, ListMixin, RESTM
             ),
         ),
         "jira": (
-            ("url", "username", "password",),
+            (
+                "url",
+                "username",
+                "password",
+            ),
             (
                 "api_url",
                 "active",


Test patch:
diff --git a/gitlab/tests/objects/test_commits.py b/gitlab/tests/objects/test_commits.py
--- a/gitlab/tests/objects/test_commits.py
+++ b/gitlab/tests/objects/test_commits.py
@@ -88,7 +88,13 @@ def test_create_commit(project, resp_create_commit):
     data = {
         "branch": "master",
         "commit_message": "Commit message",
-        "actions": [{"action": "create", "file_path": "README", "content": "",}],
+        "actions": [
+            {
+                "action": "create",
+                "file_path": "README",
+                "content": "",
+            }
+        ],
     }
     commit = project.commits.create(data)
     assert commit.short_id == "ed899a2f"
diff --git a/gitlab/tests/objects/test_resource_milestone_events.py b/gitlab/tests/objects/test_resource_milestone_events.py
new file mode 100644
--- /dev/null
+++ b/gitlab/tests/objects/test_resource_milestone_events.py
@@ -0,0 +1,73 @@
+"""
+GitLab API: https://docs.gitlab.com/ee/api/resource_milestone_events.html
+"""
+
+import pytest
+import responses
+
+from gitlab.v4.objects import (
+    ProjectIssueResourceMilestoneEvent,
+    ProjectMergeRequestResourceMilestoneEvent,
+)
+
+
+@pytest.fixture()
+def resp_merge_request_milestone_events():
+    mr_content = {"iid": 1}
+    events_content = {"id": 1, "resource_type": "MergeRequest"}
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests",
+            json=[mr_content],
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/merge_requests/1/resource_milestone_events",
+            json=[events_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture()
+def resp_project_issue_milestone_events():
+    issue_content = {"iid": 1}
+    events_content = {"id": 1, "resource_type": "Issue"}
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/issues",
+            json=[issue_content],
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/issues/1/resource_milestone_events",
+            json=[events_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+def test_project_issue_milestone_events(project, resp_project_issue_milestone_events):
+    issue = project.issues.list()[0]
+    milestone_events = issue.resourcemilestoneevents.list()
+    assert isinstance(milestone_events, list)
+    milestone_event = milestone_events[0]
+    assert isinstance(milestone_event, ProjectIssueResourceMilestoneEvent)
+    assert milestone_event.resource_type == "Issue"
+
+
+def test_merge_request_milestone_events(project, resp_merge_request_milestone_events):
+    mr = project.mergerequests.list()[0]
+    milestone_events = mr.resourcemilestoneevents.list()
+    assert isinstance(milestone_events, list)
+    milestone_event = milestone_events[0]
+    assert isinstance(milestone_event, ProjectMergeRequestResourceMilestoneEvent)
+    assert milestone_event.resource_type == "MergeRequest"
diff --git a/gitlab/tests/objects/test_runners.py b/gitlab/tests/objects/test_runners.py
--- a/gitlab/tests/objects/test_runners.py
+++ b/gitlab/tests/objects/test_runners.py
@@ -167,7 +167,9 @@ def resp_runner_delete():
             status=200,
         )
         rsps.add(
-            method=responses.DELETE, url=pattern, status=204,
+            method=responses.DELETE,
+            url=pattern,
+            status=204,
         )
         yield rsps
 
@@ -177,7 +179,9 @@ def resp_runner_disable():
     with responses.RequestsMock() as rsps:
         pattern = re.compile(r".*?/(groups|projects)/1/runners/6")
         rsps.add(
-            method=responses.DELETE, url=pattern, status=204,
+            method=responses.DELETE,
+            url=pattern,
+            status=204,
         )
         yield rsps
 
@@ -187,7 +191,9 @@ def resp_runner_verify():
     with responses.RequestsMock() as rsps:
         pattern = re.compile(r".*?/runners/verify")
         rsps.add(
-            method=responses.POST, url=pattern, status=200,
+            method=responses.POST,
+            url=pattern,
+            status=200,
         )
         yield rsps
 
diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py
--- a/tools/python_test_v4.py
+++ b/tools/python_test_v4.py
@@ -701,7 +701,12 @@
 
 # Remove once https://gitlab.com/gitlab-org/gitlab/-/issues/211878 is fixed
 deploy_token = deploy_token_group.deploytokens.create(
-    {"name": "foo", "username": "", "expires_at": "", "scopes": ["read_repository"],}
+    {
+        "name": "foo",
+        "username": "",
+        "expires_at": "",
+        "scopes": ["read_repository"],
+    }
 )
 
 assert len(deploy_token_group.deploytokens.list()) == 1
@@ -782,6 +787,11 @@
 event = issue1.resourcelabelevents.get(events[0].id)
 assert event
 
+# issue milestones
+milestones = issue1.resourcemilestoneevents.list()
+assert milestones
+milestone = issue1.resourcemilestoneevents.get(milestones[0].id)
+assert milestone
 
 size = len(issue1.discussions.list())
 discussion = issue1.discussions.create({"body": "Discussion body"})
@@ -891,6 +901,14 @@
 event = mr.resourcelabelevents.get(events[0].id)
 assert event
 
+# mr milestone events
+mr.milestone_id = m1.id
+mr.save()
+milestones = mr.resourcemilestoneevents.list()
+assert milestones
+milestone = mr.resourcemilestoneevents.get(milestones[0].id)
+assert milestone
+
 # rebasing
 assert mr.rebase()