Skip to content

Commit d9b8a55

Browse files
fix(epics): use actual group_id for save/delete operations on nested epics
When an epic belonging to a subgroup is retrieved through a parent group's epic listing, save() and delete() operations would fail because they used the parent group's path instead of the epic's actual group_id. This commit overrides save() and delete() methods in GroupEpic to use the epic's group_id attribute to construct the correct API path, ensuring operations work correctly regardless of how the epic was retrieved. This commit was created with the assistance of AI Closes: #3261
1 parent f78a873 commit d9b8a55

3 files changed

Lines changed: 160 additions & 0 deletions

File tree

‎gitlab/v4/objects/epics.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from typing import Any, TYPE_CHECKING
44

5+
import gitlab
56
from gitlab import exceptions as exc
67
from gitlab import types
78
from gitlab.base import RESTObject
@@ -24,11 +25,87 @@
2425

2526
class GroupEpic(ObjectDeleteMixin, SaveMixin, RESTObject):
2627
_id_attr = "iid"
28+
manager: GroupEpicManager
2729

2830
issues: GroupEpicIssueManager
2931
resourcelabelevents: GroupEpicResourceLabelEventManager
3032
notes: GroupEpicNoteManager
3133

34+
def _epic_path(self) -> str:
35+
"""Return the API path for this epic using its real group."""
36+
encoded_group_id = getattr(self, "group_id", None)
37+
if encoded_group_id is None:
38+
raise AttributeError(
39+
"Cannot compute epic path: attribute 'group_id' is missing."
40+
)
41+
return f"/groups/{gitlab.utils.EncodedId(encoded_group_id)}/epics/{self.encoded_id}"
42+
43+
@exc.on_http_error(exc.GitlabUpdateError)
44+
def save(self, **kwargs: Any) -> dict[str, Any] | None:
45+
"""Save the changes made to the object to the server.
46+
47+
The object is updated to match what the server returns.
48+
49+
This method uses the epic's group_id attribute to construct the correct
50+
API path. This is important when the epic was retrieved from a parent
51+
group but actually belongs to a sub-group.
52+
53+
Args:
54+
**kwargs: Extra options to send to the server (e.g. sudo)
55+
56+
Returns:
57+
The new object data (*not* a RESTObject)
58+
59+
Raises:
60+
GitlabAuthenticationError: If authentication is not correct
61+
GitlabUpdateError: If the server cannot perform the request
62+
"""
63+
updated_data = self._get_updated_data()
64+
# Nothing to update. Server fails if sent an empty dict.
65+
if not updated_data:
66+
return None
67+
68+
# Use the epic's actual group_id to construct the correct path.
69+
path = self._epic_path()
70+
71+
# Validate and transform the data
72+
excludes = [self._id_attr] if self._id_attr else []
73+
self.manager._update_attrs.validate_attrs(data=updated_data, excludes=excludes)
74+
75+
updated_data, files = gitlab.utils._transform_types(
76+
data=updated_data, custom_types=self.manager._types, transform_data=False
77+
)
78+
79+
# Make the request
80+
http_method = self.manager._get_update_method()
81+
server_data = http_method(path, post_data=updated_data, files=files, **kwargs)
82+
if TYPE_CHECKING:
83+
assert isinstance(server_data, dict)
84+
self._update_attrs(server_data)
85+
return server_data
86+
87+
@exc.on_http_error(exc.GitlabDeleteError)
88+
def delete(self, **kwargs: Any) -> None:
89+
"""Delete the object from the server.
90+
91+
This method uses the epic's group_id attribute to construct the correct
92+
API path. This is important when the epic was retrieved from a parent
93+
group but actually belongs to a sub-group.
94+
95+
Args:
96+
**kwargs: Extra options to send to the server (e.g. sudo)
97+
98+
Raises:
99+
GitlabAuthenticationError: If authentication is not correct
100+
GitlabDeleteError: If the server cannot perform the request
101+
"""
102+
if TYPE_CHECKING:
103+
assert self.encoded_id is not None
104+
105+
# Use the epic's actual group_id to construct the correct path.
106+
path = self._epic_path()
107+
self.manager.gitlab.http_delete(path, **kwargs)
108+
32109

33110
class GroupEpicManager(CRUDMixin[GroupEpic]):
34111
_path = "/groups/{group_id}/epics"

‎tests/functional/api/test_epics.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import uuid
2+
13
import pytest
24

5+
from tests.functional import helpers
6+
37
pytestmark = pytest.mark.gitlab_premium
48

59

@@ -30,3 +34,34 @@ def test_epic_notes(epic):
3034

3135
epic.notes.create({"body": "Test note"})
3236
assert epic.notes.list()
37+
38+
39+
def test_epic_save_from_parent_group_updates_subgroup_epic(group):
40+
subgroup_id = uuid.uuid4().hex
41+
subgroup = group.subgroups.create(
42+
{"name": f"subgroup-{subgroup_id}", "path": f"sg-{subgroup_id}"}
43+
)
44+
45+
nested_epic = subgroup.epics.create(
46+
{"title": f"Nested epic {subgroup_id}", "description": "Nested epic"}
47+
)
48+
49+
try:
50+
fetched_epics = group.epics.list(search=nested_epic.title)
51+
assert fetched_epics, "Expected to discover nested epic via parent group list"
52+
53+
fetched_epic = next(
54+
(epic for epic in fetched_epics if epic.id == nested_epic.id),
55+
None,
56+
)
57+
assert fetched_epic is not None, "Parent group listing did not include nested epic"
58+
59+
new_label = f"nested-{subgroup_id}"
60+
fetched_epic.labels = [new_label]
61+
fetched_epic.save()
62+
63+
refreshed_epic = subgroup.epics.get(nested_epic.iid)
64+
assert new_label in refreshed_epic.labels
65+
finally:
66+
helpers.safe_delete(nested_epic)
67+
helpers.safe_delete(subgroup)

‎tests/unit/objects/test_epics.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import responses
2+
3+
from gitlab.v4.objects.epics import GroupEpic
4+
5+
6+
def _build_epic(manager, iid=3, group_id=2, title="Epic"):
7+
data = {
8+
"iid": iid,
9+
"group_id": group_id,
10+
"title": title,
11+
}
12+
return GroupEpic(manager, data)
13+
14+
15+
def test_group_epic_save_uses_actual_group_path(group):
16+
epic_manager = group.epics
17+
epic = _build_epic(epic_manager, title="Original")
18+
epic.title = "Updated"
19+
20+
with responses.RequestsMock() as rsps:
21+
rsps.add(
22+
method=responses.PUT,
23+
url="http://localhost/api/v4/groups/2/epics/3",
24+
json={"iid": 3, "group_id": 2, "title": "Updated"},
25+
content_type="application/json",
26+
status=200,
27+
match=[responses.matchers.json_params_matcher({"title": "Updated"})],
28+
)
29+
30+
epic.save()
31+
32+
assert epic.title == "Updated"
33+
34+
35+
def test_group_epic_delete_uses_actual_group_path(group):
36+
epic_manager = group.epics
37+
epic = _build_epic(epic_manager)
38+
39+
with responses.RequestsMock() as rsps:
40+
rsps.add(
41+
method=responses.DELETE,
42+
url="http://localhost/api/v4/groups/2/epics/3",
43+
status=204,
44+
)
45+
46+
epic.delete()
47+
48+
assert len(epic._updated_attrs) == 0

0 commit comments

Comments
 (0)