Skip to content
Open
12 changes: 12 additions & 0 deletions docs/gl_objects/merge_trains.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Reference

+ :class:`gitlab.v4.objects.ProjectMergeTrain`
+ :class:`gitlab.v4.objects.ProjectMergeTrainManager`
+ :class:`gitlab.v4.objects.ProjectMergeTrainMergeRequest`
+ :class:`gitlab.v4.objects.ProjectMergeTrainMergeRequestManager`
+ :attr:`gitlab.v4.objects.Project.merge_trains`

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

merge_trains = project.merge_trains.list(scope="complete")

Get Merge Request Status for a Merge Train::

merge_train_mr = project.merge_trains.get(1, lazy=True).merge_requests.get(1)
merge_train_mr_status = merge_train_mr.pipeline.get("status")

Add Merge Request to a Merge Train::

merge_train_to_update = project.merge_trains.get(1, lazy=True)
merge_requests_update = merge_train_to_update.merge_requests.update(5, new_data={"sha": "cd22awr721ssds"})
39 changes: 34 additions & 5 deletions gitlab/v4/objects/merge_trains.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,43 @@
from gitlab.base import RESTObject
from gitlab.mixins import ListMixin
from gitlab.base import RESTManager, RESTObject
from gitlab.mixins import GetMixin, ListMixin, UpdateMethod, UpdateMixin
from gitlab.types import RequiredOptional

__all__ = ["ProjectMergeTrain", "ProjectMergeTrainManager"]
__all__ = [
"ProjectMergeTrain",
"ProjectMergeTrainManager",
"ProjectMergeTrainMergeRequest",
"ProjectMergeTrainMergeRequestManager",
]


class ProjectMergeTrain(RESTObject):
class ProjectMergeTrainMergeRequest(RESTObject):
pass


class ProjectMergeTrainManager(ListMixin[ProjectMergeTrain]):
class ProjectMergeTrainMergeRequestManager(
GetMixin[ProjectMergeTrainMergeRequest],
UpdateMixin[ProjectMergeTrainMergeRequest],
RESTManager[ProjectMergeTrainMergeRequest],
):
_path = "/projects/{project_id}/merge_trains/merge_requests"
_obj_cls = ProjectMergeTrainMergeRequest
_from_parent_attrs = {"project_id": "project_id"}
_update_method: UpdateMethod = UpdateMethod.POST
Comment on lines +13 to +25
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProjectMergeTrainMergeRequestManager.get(<id>) and .update(<id>) use the merge request IID in the URL, but ProjectMergeTrainMergeRequest currently uses the default _id_attr = "id" (the merge-train entry id returned by the API). That makes merge_train_mr.get_id() return a different identifier than what the manager methods expect, which is error-prone for users. Consider overriding _id_attr/get_id() so the object identifier aligns with the merge request IID used by these endpoints (or otherwise make the distinction explicit).

Copilot uses AI. Check for mistakes.

_update_attrs = RequiredOptional(
optional=("sha", "squash", "when_pipeline_succeeds", "auto_merge")
)


class ProjectMergeTrain(RESTObject):
merge_requests: ProjectMergeTrainMergeRequestManager


class ProjectMergeTrainManager(
GetMixin[ProjectMergeTrain],
ListMixin[ProjectMergeTrain],
RESTManager[ProjectMergeTrain],
):
_path = "/projects/{project_id}/merge_trains"
_obj_cls = ProjectMergeTrain
_from_parent_attrs = {"project_id": "id"}
Expand Down
2 changes: 1 addition & 1 deletion gitlab/v4/objects/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
ProjectApprovalRuleManager,
)
from .merge_requests import ProjectMergeRequestManager # noqa: F401
from .merge_trains import ProjectMergeTrainManager # noqa: F401
from .merge_trains import ProjectMergeTrainManager
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProjectMergeTrainManager is imported but only referenced via annotations/manager auto-creation, which flake8/pyflakes still treats as an unused import in this file (many similar imports here use # noqa: F401). Removing # noqa: F401 is likely to trigger F401 in the lint job; please restore the ignore (or otherwise reference the name at runtime).

Suggested change
from .merge_trains import ProjectMergeTrainManager
from .merge_trains import ProjectMergeTrainManager # noqa: F401

Copilot uses AI. Check for mistakes.
from .milestones import ProjectMilestoneManager # noqa: F401
from .notes import ProjectNoteManager # noqa: F401
from .notification_settings import ProjectNotificationSettingsManager # noqa: F401
Expand Down
64 changes: 62 additions & 2 deletions tests/unit/objects/test_merge_trains.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
https://docs.gitlab.com/ee/api/merge_trains.html
"""

from copy import deepcopy

import pytest
import responses

from gitlab.v4.objects import ProjectMergeTrain
from gitlab.v4.objects import ProjectMergeTrain, ProjectMergeTrainMergeRequest

mr_content = {
"id": 110,
"merge_request": {
"id": 1,
"id": 273,
"iid": 1,
"project_id": 3,
"title": "Test merge train",
Expand Down Expand Up @@ -46,6 +48,10 @@
"duration": 70,
}

merge_train_update = deepcopy(mr_content)
merge_train_update["iid"] = 4
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test data for merge_train_update sets a top-level iid key, but mr_content models iid under the nested merge_request object. This makes the mocked POST response inconsistent with the schema used elsewhere in the test and can hide mistakes in assertions. Update the fixture so the merge request IID change is applied to merge_train_update["merge_request"]["iid"] (and/or adjust other fields) rather than adding a new top-level key.

Suggested change
merge_train_update["iid"] = 4
merge_train_update["merge_request"]["iid"] = 4

Copilot uses AI. Check for mistakes.
merge_train_update["pipeline"]["sha"] = "ef33a3zxc3"


@pytest.fixture
def resp_list_merge_trains():
Expand All @@ -60,7 +66,61 @@ def resp_list_merge_trains():
yield rsps


@pytest.fixture
def resp_merge_trains_merge_request_get():
with responses.RequestsMock() as rsps:
rsps.add(
method=responses.GET,
url="http://localhost/api/v4/projects/1/merge_trains/merge_requests/1",
json=mr_content,
content_type="application/json",
status=200,
)
yield rsps


@pytest.fixture
def resp_merge_trains_merge_request_post():
with responses.RequestsMock() as rsps:
rsps.add(
method=responses.POST,
url="http://localhost/api/v4/projects/1/merge_trains/merge_requests/4",
json=[merge_train_update],
content_type="application/json",
status=200,
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The POST fixture for merge_trains/merge_requests/:iid doesn't assert the request body, so the test can pass even if update() sends the wrong payload (or no payload). Consider adding a responses.matchers.json_params_matcher (or equivalent) to validate that the expected fields (e.g., sha) are included in the POST body.

Suggested change
status=200,
status=200,
match=[
responses.matchers.json_params_matcher({"sha": "ef33a3zxc3"}),
],

Copilot uses AI. Check for mistakes.
)
yield rsps


def test_list_project_merge_requests(project, resp_list_merge_trains):
merge_trains = project.merge_trains.list()
assert isinstance(merge_trains[0], ProjectMergeTrain)
assert merge_trains[0].id == mr_content["id"]


def test_merge_trains_status_merge_request(
project, resp_merge_trains_merge_request_get
):
merge_train_mr: ProjectMergeTrainMergeRequest = project.merge_trains.get(
1, lazy=True
).merge_requests.get(1)
assert isinstance(merge_train_mr, ProjectMergeTrainMergeRequest)
assert merge_train_mr.get_id() == 110
assert merge_train_mr.merge_request["iid"] == mr_content["merge_request"]["iid"]
assert merge_train_mr.pipeline.get("status") == mr_content["pipeline"]["status"]


def test_merge_train_add_merge_request(project, resp_merge_trains_merge_request_post):
merge_train: ProjectMergeTrain = project.merge_trains.get(1, lazy=True)
merge_requests_update = merge_train.merge_requests.update(
4, new_data={"sha": "ef33a3zxc3"}
)
assert isinstance(merge_train, ProjectMergeTrain)
assert (
merge_requests_update[0]["pipeline"]["sha"]
== merge_train_update["pipeline"]["sha"]
)
assert (
merge_requests_update[0]["merge_request"]["iid"]
== merge_train_update["merge_request"]["iid"]
)
Loading