| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 0f5655c commit 27bd80c
4 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -314,6 +314,8 @@ def update( | |||
| 314 | 314 | path = self.path | |
| 315 | 315 | else: | |
| 316 | 316 | path = f"{self.path}/{utils.EncodedId(id)}" | |
| 317 | + if "_pg_custom_path" in kwargs: | ||
| 318 | + path = kwargs.pop("_pg_custom_path") | ||
| 317 | 319 | ||
| 318 | 320 | excludes = [] | |
| 319 | 321 | if self._obj_cls is not None and self._obj_cls._id_attr is not None: | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -2,6 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | from typing import Any, TYPE_CHECKING | |
| 4 | 4 | ||
| 5 | + import gitlab.utils | ||
| 5 | 6 | from gitlab import exceptions as exc | |
| 6 | 7 | from gitlab import types | |
| 7 | 8 | from gitlab.base import RESTObject | |
@@ -24,11 +25,69 @@ | |||
| 24 | 25 | ||
| 25 | 26 | class GroupEpic(ObjectDeleteMixin, SaveMixin, RESTObject): | |
| 26 | 27 | _id_attr = "iid" | |
| 28 | + manager: GroupEpicManager | ||
| 27 | 29 | ||
| 28 | 30 | issues: GroupEpicIssueManager | |
| 29 | 31 | resourcelabelevents: GroupEpicResourceLabelEventManager | |
| 30 | 32 | notes: GroupEpicNoteManager | |
| 31 | 33 | ||
| 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 | + | ||
| 32 | 91 | ||
| 33 | 92 | class GroupEpicManager(CRUDMixin[GroupEpic]): | |
| 34 | 93 | _path = "/groups/{group_id}/epics" | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1,5 +1,9 @@ | |||
| 1 | + import uuid | ||
| 2 | + | ||
| 1 | 3 | import pytest | |
| 2 | 4 | ||
| 5 | + from tests.functional import helpers | ||
| 6 | + | ||
| 3 | 7 | pytestmark = pytest.mark.gitlab_premium | |
| 4 | 8 | ||
| 5 | 9 | ||
@@ -30,3 +34,39 @@ def test_epic_notes(epic): | |||
| 30 | 34 | ||
| 31 | 35 | epic.notes.create({"body": "Test note"}) | |
| 32 | 36 | 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) | ||
| Original file line number | Diff line number | Diff 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 | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments