Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 27bd80c

Browse filesBrowse 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. Also add the ability to pass a custom path using `_pg_custom_path` to the `UpdateMixin.update()` and `SaveMixin.save()` methods. This allowed the override of the `update()` method to re-use the `SaveMixin.save()` method. Closes: #3261
1 parent 0f5655c commit 27bd80c
Copy full SHA for 27bd80c

File tree

Expand file treeCollapse file tree

4 files changed

+145
-0
lines changed
Open diff view settings
Filter options
Expand file treeCollapse file tree

4 files changed

+145
-0
lines changed
Open diff view settings
Collapse file

‎gitlab/mixins.py‎

Copy file name to clipboardExpand all lines: gitlab/mixins.py
+2Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,8 @@ def update(
314314
path = self.path
315315
else:
316316
path = f"{self.path}/{utils.EncodedId(id)}"
317+
if "_pg_custom_path" in kwargs:
318+
path = kwargs.pop("_pg_custom_path")
317319

318320
excludes = []
319321
if self._obj_cls is not None and self._obj_cls._id_attr is not None:
Collapse file

‎gitlab/v4/objects/epics.py‎

Copy file name to clipboardExpand all lines: gitlab/v4/objects/epics.py
+59Lines changed: 59 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.utils
56
from gitlab import exceptions as exc
67
from gitlab import types
78
from gitlab.base import RESTObject
@@ -24,11 +25,69 @@
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+
if not hasattr(self, "group_id") or self.group_id is None:
37+
raise AttributeError(
38+
"Cannot compute epic path: attribute 'group_id' is missing."
39+
)
40+
encoded_group_id = gitlab.utils.EncodedId(self.group_id)
41+
return f"/groups/{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+
# Use the epic's actual group_id to construct the correct path.
64+
path = self._epic_path()
65+
66+
# Call SaveMixin.save() method
67+
return super().save(_pg_custom_path=path, **kwargs)
68+
69+
@exc.on_http_error(exc.GitlabDeleteError)
70+
def delete(self, **kwargs: Any) -> None:
71+
"""Delete the object from the server.
72+
73+
This method uses the epic's group_id attribute to construct the correct
74+
API path. This is important when the epic was retrieved from a parent
75+
group but actually belongs to a sub-group.
76+
77+
Args:
78+
**kwargs: Extra options to send to the server (e.g. sudo)
79+
80+
Raises:
81+
GitlabAuthenticationError: If authentication is not correct
82+
GitlabDeleteError: If the server cannot perform the request
83+
"""
84+
if TYPE_CHECKING:
85+
assert self.encoded_id is not None
86+
87+
# Use the epic's actual group_id to construct the correct path.
88+
path = self._epic_path()
89+
self.manager.gitlab.http_delete(path, **kwargs)
90+
3291

3392
class GroupEpicManager(CRUDMixin[GroupEpic]):
3493
_path = "/groups/{group_id}/epics"
Collapse file

‎tests/functional/api/test_epics.py‎

Copy file name to clipboardExpand all lines: tests/functional/api/test_epics.py
+40Lines changed: 40 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,39 @@ 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(gl, group):
40+
subgroup_id = uuid.uuid4().hex
41+
subgroup = gl.groups.create(
42+
{
43+
"name": f"subgroup-{subgroup_id}",
44+
"path": f"sg-{subgroup_id}",
45+
"parent_id": group.id,
46+
}
47+
)
48+
49+
nested_epic = subgroup.epics.create(
50+
{"title": f"Nested epic {subgroup_id}", "description": "Nested epic"}
51+
)
52+
53+
try:
54+
fetched_epics = group.epics.list(search=nested_epic.title)
55+
assert fetched_epics, "Expected to discover nested epic via parent group list"
56+
57+
fetched_epic = next(
58+
(epic for epic in fetched_epics if epic.id == nested_epic.id), None
59+
)
60+
assert (
61+
fetched_epic is not None
62+
), "Parent group listing did not include nested epic"
63+
64+
new_label = f"nested-{subgroup_id}"
65+
fetched_epic.labels = [new_label]
66+
fetched_epic.save()
67+
68+
refreshed_epic = subgroup.epics.get(nested_epic.iid)
69+
assert new_label in refreshed_epic.labels
70+
finally:
71+
helpers.safe_delete(nested_epic)
72+
helpers.safe_delete(subgroup)
Collapse file

‎tests/unit/objects/test_epics.py‎

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

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.