fix(epics): use actual group_id for save/delete operations on nested … · python-gitlab/python-gitlab@83feda8 · GitHub
Skip to content

Commit 83feda8

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. 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 659c648 commit 83feda8

4 files changed

Lines changed: 187 additions & 0 deletions

File tree

gitlab/mixins.py

Lines changed: 2 additions & 0 deletions

gitlab/v4/objects/epics.py

Lines changed: 58 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
@@ -29,6 +30,63 @@ class GroupEpic(ObjectDeleteMixin, SaveMixin, RESTObject):
2930
resourcelabelevents: GroupEpicResourceLabelEventManager
3031
notes: GroupEpicNoteManager
3132

33+
def _epic_path(self) -> str:
34+
"""Return the API path for this epic using its real group."""
35+
if not hasattr(self, "group_id") or self.group_id is None:
36+
raise AttributeError(
37+
"Cannot compute epic path: attribute 'group_id' is missing."
38+
)
39+
encoded_group_id = gitlab.utils.EncodedId(self.group_id)
40+
return f"/groups/{encoded_group_id}/epics/{self.encoded_id}"
41+
42+
@exc.on_http_error(exc.GitlabUpdateError)
43+
def save(self, **kwargs: Any) -> dict[str, Any] | None:
44+
"""Save the changes made to the object to the server.
45+
46+
The object is updated to match what the server returns.
47+
48+
This method uses the epic's group_id attribute to construct the correct
49+
API path. This is important when the epic was retrieved from a parent
50+
group but actually belongs to a sub-group.
51+
52+
Args:
53+
**kwargs: Extra options to send to the server (e.g. sudo)
54+
55+
Returns:
56+
The new object data (*not* a RESTObject)
57+
58+
Raises:
59+
GitlabAuthenticationError: If authentication is not correct
60+
GitlabUpdateError: If the server cannot perform the request
61+
"""
62+
# Use the epic's actual group_id to construct the correct path.
63+
path = self._epic_path()
64+
65+
# Call SaveMixin.save() method
66+
return super().save(_pg_custom_path=path, **kwargs)
67+
68+
@exc.on_http_error(exc.GitlabDeleteError)
69+
def delete(self, **kwargs: Any) -> None:
70+
"""Delete the object from the server.
71+
72+
This method uses the epic's group_id attribute to construct the correct
73+
API path. This is important when the epic was retrieved from a parent
74+
group but actually belongs to a sub-group.
75+
76+
Args:
77+
**kwargs: Extra options to send to the server (e.g. sudo)
78+
79+
Raises:
80+
GitlabAuthenticationError: If authentication is not correct
81+
GitlabDeleteError: If the server cannot perform the request
82+
"""
83+
if TYPE_CHECKING:
84+
assert self.encoded_id is not None
85+
86+
# Use the epic's actual group_id to construct the correct path.
87+
path = self._epic_path()
88+
self.manager.gitlab.http_delete(path, **kwargs)
89+
3290

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

tests/functional/api/test_epics.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
1+
import collections.abc
2+
import dataclasses
3+
import uuid
4+
15
import pytest
26

7+
import gitlab
8+
import gitlab.v4.objects.epics
9+
import gitlab.v4.objects.groups
10+
from tests.functional import helpers
11+
312
pytestmark = pytest.mark.gitlab_premium
413

514

15+
@dataclasses.dataclass(frozen=True)
16+
class NestedEpicInSubgroup:
17+
subgroup: gitlab.v4.objects.groups.Group
18+
nested_epic: gitlab.v4.objects.epics.GroupEpic
19+
20+
621
def test_epics(group):
722
epic = group.epics.create({"title": "Test epic"})
823
epic.title = "Fixed title"
@@ -32,3 +47,48 @@ def test_epic_notes(epic):
3247
epic.notes.create({"body": "Test note"})
3348
new_notes = epic.notes.list(get_all=True)
3449
assert len(new_notes) == (len(notes) + 1), f"{new_notes} {notes}"
50+
51+
52+
@pytest.fixture
53+
def nested_epic_in_subgroup(
54+
gl: gitlab.Gitlab, group: gitlab.v4.objects.groups.Group
55+
) -> collections.abc.Generator[NestedEpicInSubgroup, None, None]:
56+
subgroup_id = uuid.uuid4().hex
57+
subgroup = gl.groups.create(
58+
{
59+
"name": f"subgroup-{subgroup_id}",
60+
"path": f"sg-{subgroup_id}",
61+
"parent_id": group.id,
62+
}
63+
)
64+
65+
nested_epic = subgroup.epics.create(
66+
{"title": f"Nested epic {subgroup_id}", "description": "Nested epic"}
67+
)
68+
69+
try:
70+
yield NestedEpicInSubgroup(subgroup=subgroup, nested_epic=nested_epic)
71+
finally:
72+
helpers.safe_delete(nested_epic)
73+
helpers.safe_delete(subgroup)
74+
75+
76+
def test_epic_save_from_parent_group_updates_subgroup_epic(
77+
group: gitlab.v4.objects.groups.Group, nested_epic_in_subgroup: NestedEpicInSubgroup
78+
) -> None:
79+
fetched_epics = group.epics.list(search=nested_epic_in_subgroup.nested_epic.title)
80+
assert fetched_epics, "Expected to discover nested epic via parent group list"
81+
82+
fetched_epic = fetched_epics[0]
83+
assert (
84+
fetched_epic.id == nested_epic_in_subgroup.nested_epic.id
85+
), "Parent group listing did not include nested epic"
86+
87+
new_label = f"nested-{uuid.uuid4().hex}"
88+
fetched_epic.labels = [new_label]
89+
fetched_epic.save()
90+
91+
refreshed_epic = nested_epic_in_subgroup.subgroup.epics.get(
92+
nested_epic_in_subgroup.nested_epic.iid
93+
)
94+
assert new_label in refreshed_epic.labels

tests/unit/objects/test_epics.py

Lines changed: 67 additions & 0 deletions

0 commit comments

Comments
 (0)