Skip to content

Commit 64e017d

Browse files
committed
feat(functional): merge-train api add and status #2547
Signed-off-by: Isaac Philip <4974658+isaac-philip@users.noreply.github.com>
1 parent 659c648 commit 64e017d

6 files changed

Lines changed: 236 additions & 11 deletions

File tree

docs/gl_objects/merge_trains.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Reference
99

1010
+ :class:`gitlab.v4.objects.ProjectMergeTrain`
1111
+ :class:`gitlab.v4.objects.ProjectMergeTrainManager`
12+
+ :class:`gitlab.v4.objects.ProjectMergeTrainMergeRequest`
13+
+ :class:`gitlab.v4.objects.ProjectMergeTrainMergeRequestManager`
1214
+ :attr:`gitlab.v4.objects.Project.merge_trains`
1315

1416
* GitLab API: https://docs.gitlab.com/api/merge_trains
@@ -27,3 +29,13 @@ List active merge trains for a project::
2729
List completed (have been merged) merge trains for a project::
2830

2931
merge_trains = project.merge_trains.list(scope="complete")
32+
33+
Get Merge Request Status for a Merge Train::
34+
35+
merge_train_mr = project.merge_trains.get(1, lazy=True).merge_requests.get(1)
36+
merge_train_mr_status = merge_train_mr.pipeline.get("status")
37+
38+
Add Merge Request to a Merge Train::
39+
40+
merge_train_to_update = project.merge_trains.get(1, lazy=True)
41+
merge_requests_update = merge_train_to_update.merge_requests.update(5, new_data={"sha": "cd22awr721ssds"})

gitlab/base.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import textwrap
88
from collections.abc import Iterable
99
from types import ModuleType
10-
from typing import Any, ClassVar, Generic, TYPE_CHECKING, TypeVar
10+
from typing import Any, cast, ClassVar, Generic, TYPE_CHECKING, TypeVar
1111

1212
import gitlab
1313
from gitlab import types as g_types
@@ -351,6 +351,7 @@ class RESTManager(Generic[TObjCls]):
351351
_path: ClassVar[str]
352352
_obj_cls: type[TObjCls]
353353
_from_parent_attrs: dict[str, Any] = {}
354+
_parent_ref_attr: ClassVar[str | None] = None
354355
_types: dict[str, type[g_types.GitlabAttribute]] = {}
355356

356357
_computed_path: str
@@ -389,6 +390,18 @@ def _compute_path(self, path: str | None = None) -> str:
389390
self._parent_attrs = data
390391
return path.format(**data)
391392

393+
def _get_parent_ref_id(self) -> int | str | None:
394+
if self._parent is None or not self._parent_ref_attr:
395+
return None
396+
if not hasattr(self._parent, self._parent_ref_attr):
397+
return None
398+
parent_ref = getattr(self._parent, self._parent_ref_attr)
399+
if parent_ref is None:
400+
return None
401+
if hasattr(parent_ref, "iid"):
402+
return cast(int, parent_ref.iid)
403+
return None
404+
392405
@property
393406
def path(self) -> str:
394407
return self._computed_path

gitlab/mixins.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,14 @@ class GetMixin(HeadMixin[base.TObjCls]):
7272
_optional_get_attrs: tuple[str, ...] = ()
7373

7474
@exc.on_http_error(exc.GitlabGetError)
75-
def get(self, id: str | int, lazy: bool = False, **kwargs: Any) -> base.TObjCls:
75+
def get(
76+
self, id: str | int | None = None, lazy: bool = False, **kwargs: Any
77+
) -> base.TObjCls:
7678
"""Retrieve a single object.
7779
7880
Args:
79-
id: ID of the object to retrieve
81+
id: ID of the object to retrieve. If not provided, falls back to
82+
_parent_ref_id from the parent object (if available).
8083
lazy: If True, don't request the server, but create a
8184
shallow object giving access to the managers. This is
8285
useful if you want to avoid useless calls to the API.
@@ -89,6 +92,13 @@ def get(self, id: str | int, lazy: bool = False, **kwargs: Any) -> base.TObjCls:
8992
GitlabAuthenticationError: If authentication is not correct
9093
GitlabGetError: If the server cannot perform the request
9194
"""
95+
if id is None:
96+
id = self._get_parent_ref_id()
97+
if id is None:
98+
raise ValueError(
99+
"id is required. Either provide it explicitly or set "
100+
"_parent_ref_attr on the manager to use the parent's reference."
101+
)
92102
if isinstance(id, str):
93103
id = utils.EncodedId(id)
94104
path = f"{self.path}/{id}"
@@ -310,6 +320,8 @@ def update(
310320
"""
311321
new_data = new_data or {}
312322

323+
if id is None:
324+
id = self._get_parent_ref_id()
313325
if id is None:
314326
path = self.path
315327
else:

gitlab/v4/objects/merge_trains.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,44 @@
1-
from gitlab.base import RESTObject
2-
from gitlab.mixins import ListMixin
1+
from gitlab.base import RESTManager, RESTObject
2+
from gitlab.mixins import GetMixin, ListMixin, UpdateMethod, UpdateMixin
3+
from gitlab.types import RequiredOptional
34

4-
__all__ = ["ProjectMergeTrain", "ProjectMergeTrainManager"]
5+
__all__ = [
6+
"ProjectMergeTrain",
7+
"ProjectMergeTrainManager",
8+
"ProjectMergeTrainMergeRequest",
9+
"ProjectMergeTrainMergeRequestManager",
10+
]
511

612

7-
class ProjectMergeTrain(RESTObject):
13+
class ProjectMergeTrainMergeRequest(RESTObject):
814
pass
915

1016

11-
class ProjectMergeTrainManager(ListMixin[ProjectMergeTrain]):
17+
class ProjectMergeTrainMergeRequestManager(
18+
GetMixin[ProjectMergeTrainMergeRequest],
19+
UpdateMixin[ProjectMergeTrainMergeRequest],
20+
RESTManager[ProjectMergeTrainMergeRequest],
21+
):
22+
_path = "/projects/{project_id}/merge_trains/merge_requests"
23+
_obj_cls = ProjectMergeTrainMergeRequest
24+
_from_parent_attrs = {"project_id": "project_id"}
25+
_parent_ref_attr = "merge_request"
26+
_update_method: UpdateMethod = UpdateMethod.POST
27+
28+
_update_attrs = RequiredOptional(
29+
optional=("sha", "squash", "when_pipeline_succeeds", "auto_merge")
30+
)
31+
32+
33+
class ProjectMergeTrain(RESTObject):
34+
merge_requests: ProjectMergeTrainMergeRequestManager
35+
36+
37+
class ProjectMergeTrainManager(
38+
GetMixin[ProjectMergeTrain],
39+
ListMixin[ProjectMergeTrain],
40+
RESTManager[ProjectMergeTrain],
41+
):
1242
_path = "/projects/{project_id}/merge_trains"
1343
_obj_cls = ProjectMergeTrain
1444
_from_parent_attrs = {"project_id": "id"}

tests/unit/mixins/test_mixin_methods.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,6 @@ class TestClass(UploadMixin, FakeObject):
584584
url=url,
585585
json={"id": 42, "file_name": "test.txt", "file_content": "testing contents"},
586586
status=200,
587-
match=[responses.matchers.query_param_matcher({})],
588587
)
589588

590589
mgr = FakeManager(gl)
@@ -596,3 +595,101 @@ class TestClass(UploadMixin, FakeObject):
596595
assert res_only_path["file_name"] == "test.txt"
597596
assert res_only_path["file_content"] == "testing contents"
598597
assert responses.assert_call_count(url, 1) is True
598+
599+
600+
class MockParentRefWithIID:
601+
def __init__(self, iid):
602+
self.iid = iid
603+
604+
605+
class MockParentWithRef:
606+
def __init__(self, parent_ref):
607+
self.parent_ref = parent_ref
608+
609+
610+
class MockManagerWithRefAttr(base.RESTManager):
611+
_path = "/tests/{test_id}/refs"
612+
_obj_cls = FakeObject
613+
_from_parent_attrs = {"test_id": "id"}
614+
_parent_ref_attr = "parent_ref"
615+
616+
617+
def test_get_parent_ref_id_no_parent(gl):
618+
class M(MockManagerWithRefAttr):
619+
pass
620+
621+
mgr = M(gl)
622+
assert mgr._get_parent_ref_id() is None
623+
624+
625+
def test_get_parent_ref_id_no_parent_ref_attr(gl):
626+
class M(FakeManager):
627+
pass
628+
629+
mgr = M(gl)
630+
assert mgr._get_parent_ref_id() is None
631+
632+
633+
def test_get_parent_ref_id_parent_has_no_ref_attr(gl):
634+
class M(FakeManager):
635+
_parent_ref_attr = "nonexistent"
636+
637+
parent = MockParentWithRef(None)
638+
mgr = M(gl, parent=parent)
639+
assert mgr._get_parent_ref_id() is None
640+
641+
642+
def test_get_parent_ref_id_parent_ref_is_none(gl):
643+
class M(MockManagerWithRefAttr):
644+
pass
645+
646+
parent = MockParentWithRef(None)
647+
mgr = M(gl, parent=parent)
648+
assert mgr._get_parent_ref_id() is None
649+
650+
651+
def test_get_parent_ref_id_success(gl):
652+
class M(MockManagerWithRefAttr):
653+
pass
654+
655+
parent_ref = MockParentRefWithIID(42)
656+
parent = MockParentWithRef(parent_ref)
657+
mgr = M(gl, parent=parent)
658+
assert mgr._get_parent_ref_id() == 42
659+
660+
661+
def test_get_parent_ref_id_no_iid_attribute(gl):
662+
class MockParentRefNoIID:
663+
pass
664+
665+
class M(MockManagerWithRefAttr):
666+
pass
667+
668+
parent_ref = MockParentRefNoIID()
669+
parent = MockParentWithRef(parent_ref)
670+
mgr = M(gl, parent=parent)
671+
assert mgr._get_parent_ref_id() is None
672+
673+
674+
def test_get_mixin_without_id_raises_error_when_no_parent_ref(gl):
675+
class M(GetMixin, MockManagerWithRefAttr):
676+
pass
677+
678+
mgr = M(gl)
679+
with pytest.raises(ValueError, match="id is required"):
680+
mgr.get()
681+
682+
683+
@responses.activate
684+
def test_update_mixin_without_id_no_parent_ref(gl):
685+
class M(UpdateMixin, FakeManager):
686+
_update_method = UpdateMethod.POST
687+
_obj_cls = FakeObject
688+
689+
url = "http://localhost/api/v4/tests"
690+
responses.add(method=responses.POST, url=url, json={}, status=200)
691+
692+
mgr = M(gl)
693+
result = mgr.update(new_data={"foo": "bar"})
694+
assert isinstance(result, dict)
695+
assert responses.assert_call_count(url, 1) is True

tests/unit/objects/test_merge_trains.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@
33
https://docs.gitlab.com/ee/api/merge_trains.html
44
"""
55

6+
from copy import deepcopy
7+
68
import pytest
79
import responses
810

9-
from gitlab.v4.objects import ProjectMergeTrain
11+
from gitlab.v4.objects import ProjectMergeTrain, ProjectMergeTrainMergeRequest
1012

1113
mr_content = {
1214
"id": 110,
1315
"merge_request": {
14-
"id": 1,
16+
"id": 273,
1517
"iid": 1,
1618
"project_id": 3,
1719
"title": "Test merge train",
@@ -46,6 +48,10 @@
4648
"duration": 70,
4749
}
4850

51+
merge_train_update = deepcopy(mr_content)
52+
merge_train_update["merge_request"]["iid"] = 4
53+
merge_train_update["pipeline"]["sha"] = "ef33a3zxc3"
54+
4955

5056
@pytest.fixture
5157
def resp_list_merge_trains():
@@ -60,7 +66,62 @@ def resp_list_merge_trains():
6066
yield rsps
6167

6268

69+
@pytest.fixture
70+
def resp_merge_trains_merge_request_get():
71+
with responses.RequestsMock() as rsps:
72+
rsps.add(
73+
method=responses.GET,
74+
url="http://localhost/api/v4/projects/1/merge_trains/merge_requests/1",
75+
json=mr_content,
76+
content_type="application/json",
77+
status=200,
78+
)
79+
yield rsps
80+
81+
82+
@pytest.fixture
83+
def resp_merge_trains_merge_request_post():
84+
with responses.RequestsMock() as rsps:
85+
rsps.add(
86+
method=responses.POST,
87+
url="http://localhost/api/v4/projects/1/merge_trains/merge_requests/4",
88+
json=[merge_train_update],
89+
content_type="application/json",
90+
status=200,
91+
match=[responses.matchers.json_params_matcher({"sha": "ef33a3zxc3"})],
92+
)
93+
yield rsps
94+
95+
6396
def test_list_project_merge_requests(project, resp_list_merge_trains):
6497
merge_trains = project.merge_trains.list()
6598
assert isinstance(merge_trains[0], ProjectMergeTrain)
6699
assert merge_trains[0].id == mr_content["id"]
100+
101+
102+
def test_merge_trains_status_merge_request(
103+
project, resp_merge_trains_merge_request_get
104+
):
105+
merge_train_mr: ProjectMergeTrainMergeRequest = project.merge_trains.get(
106+
1, lazy=True
107+
).merge_requests.get(1)
108+
assert isinstance(merge_train_mr, ProjectMergeTrainMergeRequest)
109+
assert merge_train_mr.get_id() == 110
110+
assert merge_train_mr.merge_request["iid"] == mr_content["merge_request"]["iid"]
111+
assert merge_train_mr.pipeline.get("status") == mr_content["pipeline"]["status"]
112+
113+
114+
def test_merge_train_add_merge_request(project, resp_merge_trains_merge_request_post):
115+
merge_train: ProjectMergeTrain = project.merge_trains.get(1, lazy=True)
116+
merge_requests_update = merge_train.merge_requests.update(
117+
4, new_data={"sha": "ef33a3zxc3"}
118+
)
119+
assert isinstance(merge_train, ProjectMergeTrain)
120+
assert (
121+
merge_requests_update[0]["pipeline"]["sha"]
122+
== merge_train_update["pipeline"]["sha"]
123+
)
124+
assert (
125+
merge_requests_update[0]["merge_request"]["iid"]
126+
== merge_train_update["merge_request"]["iid"]
127+
)

0 commit comments

Comments
 (0)