From bfc063730e6569713f48d1d275c56a6de734e72c Mon Sep 17 00:00:00 2001 From: amimas Date: Mon, 16 Feb 2026 09:50:56 -0500 Subject: [PATCH 01/10] feat(api): add support for project feature flags Add support for the Project Feature Flags API. - Add `ProjectFeatureFlag` and `ProjectFeatureFlagManager`. - Add `project.feature_flags` manager. - Add functional tests for API and CLI. - Handle JSON parsing for `strategies` attribute in CLI commands by overriding create/update methods in the manager. --- gitlab/v4/objects/__init__.py | 1 + gitlab/v4/objects/feature_flags.py | 80 ++++++++++ gitlab/v4/objects/projects.py | 2 + .../api/test_project_feature_flags.py | 58 +++++++ .../cli/test_cli_project_feature_flags.py | 143 ++++++++++++++++++ 5 files changed, 284 insertions(+) create mode 100644 gitlab/v4/objects/feature_flags.py create mode 100644 tests/functional/api/test_project_feature_flags.py create mode 100644 tests/functional/cli/test_cli_project_feature_flags.py diff --git a/gitlab/v4/objects/__init__.py b/gitlab/v4/objects/__init__.py index cc2ffeb52..84fb3651f 100644 --- a/gitlab/v4/objects/__init__.py +++ b/gitlab/v4/objects/__init__.py @@ -24,6 +24,7 @@ from .epics import * from .events import * from .export_import import * +from .feature_flags import * from .features import * from .files import * from .geo_nodes import * diff --git a/gitlab/v4/objects/feature_flags.py b/gitlab/v4/objects/feature_flags.py new file mode 100644 index 000000000..f3fd13f88 --- /dev/null +++ b/gitlab/v4/objects/feature_flags.py @@ -0,0 +1,80 @@ +""" +GitLab API: +https://docs.gitlab.com/ee/api/feature_flags.html +""" + +from __future__ import annotations + +import json +from typing import Any + +from gitlab.base import RESTObject +from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin +from gitlab.types import RequiredOptional + +__all__ = ["ProjectFeatureFlag", "ProjectFeatureFlagManager"] + + +class ProjectFeatureFlag(SaveMixin, ObjectDeleteMixin, RESTObject): + _id_attr = "name" + + +class ProjectFeatureFlagManager(CRUDMixin[ProjectFeatureFlag]): + _path = "/projects/{project_id}/feature_flags" + _obj_cls = ProjectFeatureFlag + _from_parent_attrs = {"project_id": "id"} + _create_attrs = RequiredOptional( + required=("name",), optional=("version", "description", "active", "strategies") + ) + _update_attrs = RequiredOptional(optional=("description", "active", "strategies")) + _list_filters = ("scope",) + + def create( + self, data: dict[str, Any] | None = None, **kwargs: Any + ) -> ProjectFeatureFlag: + """Create a new object. + + Args: + data: Parameters to send to the server to create the + resource + **kwargs: Extra options to send to the server (e.g. sudo) + + Returns: + A new instance of the managed object class build with + the data sent by the server + """ + # Handle strategies being passed as a JSON string (e.g. from CLI) + if "strategies" in kwargs and isinstance(kwargs["strategies"], str): + kwargs["strategies"] = json.loads(kwargs["strategies"]) + if data and "strategies" in data and isinstance(data["strategies"], str): + data["strategies"] = json.loads(data["strategies"]) + + return super().create(data, **kwargs) + + def update( + self, + id: str | int | None = None, + new_data: dict[str, Any] | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Update an object on the server. + + Args: + id: ID of the object to update (can be None if not required) + new_data: the update data for the object + **kwargs: Extra options to send to the server (e.g. sudo) + + Returns: + The new object data (*not* a RESTObject) + """ + # Handle strategies being passed as a JSON string (e.g. from CLI) + if "strategies" in kwargs and isinstance(kwargs["strategies"], str): + kwargs["strategies"] = json.loads(kwargs["strategies"]) + if ( + new_data + and "strategies" in new_data + and isinstance(new_data["strategies"], str) + ): + new_data["strategies"] = json.loads(new_data["strategies"]) + + return super().update(id, new_data, **kwargs) diff --git a/gitlab/v4/objects/projects.py b/gitlab/v4/objects/projects.py index 751ac4c1f..6f865b410 100644 --- a/gitlab/v4/objects/projects.py +++ b/gitlab/v4/objects/projects.py @@ -49,6 +49,7 @@ ) from .events import ProjectEventManager # noqa: F401 from .export_import import ProjectExportManager, ProjectImportManager # noqa: F401 +from .feature_flags import ProjectFeatureFlagManager # noqa: F401 from .files import ProjectFileManager # noqa: F401 from .hooks import ProjectHookManager # noqa: F401 from .integrations import ProjectIntegrationManager, ProjectServiceManager # noqa: F401 @@ -201,6 +202,7 @@ class Project( environments: ProjectEnvironmentManager events: ProjectEventManager exports: ProjectExportManager + feature_flags: ProjectFeatureFlagManager files: ProjectFileManager forks: ProjectForkManager generic_packages: GenericPackageManager diff --git a/tests/functional/api/test_project_feature_flags.py b/tests/functional/api/test_project_feature_flags.py new file mode 100644 index 000000000..e6a9b32f6 --- /dev/null +++ b/tests/functional/api/test_project_feature_flags.py @@ -0,0 +1,58 @@ +import pytest + +from gitlab import exceptions + + +@pytest.fixture +def feature_flag(project): + flag_name = "test_flag_fixture" + flag = project.feature_flags.create( + {"name": flag_name, "version": "new_version_flag"} + ) + yield flag + try: + flag.delete() + except exceptions.GitlabDeleteError: + pass + + +def test_create_feature_flag(project): + flag_name = "test_flag_create" + flag = project.feature_flags.create( + {"name": flag_name, "version": "new_version_flag"} + ) + assert flag.name == flag_name + assert flag.active is True + flag.delete() + + +def test_create_feature_flag_with_strategies(project): + flag_name = "test_flag_strategies" + strategies = [{"name": "userWithId", "parameters": {"userIds": "user1"}}] + flag = project.feature_flags.create( + {"name": flag_name, "version": "new_version_flag", "strategies": strategies} + ) + assert len(flag.strategies) == 1 + assert flag.strategies[0]["name"] == "userWithId" + assert flag.strategies[0]["parameters"]["userIds"] == "user1" + flag.delete() + + +def test_list_feature_flags(project, feature_flag): + flags = project.feature_flags.list() + assert len(flags) >= 1 + assert feature_flag.name in [f.name for f in flags] + + +def test_update_feature_flag(project, feature_flag): + feature_flag.active = False + feature_flag.save() + + updated_flag = project.feature_flags.get(feature_flag.name) + assert updated_flag.active is False + + +def test_delete_feature_flag(project, feature_flag): + feature_flag.delete() + with pytest.raises(exceptions.GitlabGetError): + project.feature_flags.get(feature_flag.name) diff --git a/tests/functional/cli/test_cli_project_feature_flags.py b/tests/functional/cli/test_cli_project_feature_flags.py new file mode 100644 index 000000000..3d7bfdd03 --- /dev/null +++ b/tests/functional/cli/test_cli_project_feature_flags.py @@ -0,0 +1,143 @@ +import json + +import pytest + + +@pytest.fixture +def feature_flag_cli(gitlab_cli, project): + flag_name = "test_flag_cli_fixture" + cmd = [ + "project-feature-flag", + "create", + "--project-id", + str(project.id), + "--name", + flag_name, + ] + gitlab_cli(cmd) + yield flag_name + try: + cmd = [ + "project-feature-flag", + "delete", + "--project-id", + str(project.id), + "--name", + flag_name, + ] + gitlab_cli(cmd) + except Exception: + pass + + +def test_project_feature_flag_cli_create_delete(gitlab_cli, project): + flag_name = "test_flag_cli_create" + cmd = [ + "project-feature-flag", + "create", + "--project-id", + str(project.id), + "--name", + flag_name, + ] + ret = gitlab_cli(cmd) + assert ret.success + assert flag_name in ret.stdout + + cmd = [ + "project-feature-flag", + "delete", + "--project-id", + str(project.id), + "--name", + flag_name, + ] + ret = gitlab_cli(cmd) + assert ret.success + + +def test_project_feature_flag_cli_create_with_strategies(gitlab_cli, project): + flag_name = "test_flag_cli_strategies" + strategies_json = ( + '[{"name": "userWithId", "parameters": {"userIds": "user1,user2"}}]' + ) + + cmd = [ + "project-feature-flag", + "create", + "--project-id", + str(project.id), + "--name", + flag_name, + "--strategies", + strategies_json, + ] + ret = gitlab_cli(cmd) + assert ret.success + + cmd = [ + "-o", + "json", + "project-feature-flag", + "get", + "--project-id", + str(project.id), + "--name", + flag_name, + ] + ret = gitlab_cli(cmd) + assert ret.success + data = json.loads(ret.stdout) + assert len(data["strategies"]) == 1 + assert data["strategies"][0]["name"] == "userWithId" + + +def test_project_feature_flag_cli_list(gitlab_cli, project, feature_flag_cli): + cmd = ["project-feature-flag", "list", "--project-id", str(project.id)] + ret = gitlab_cli(cmd) + assert ret.success + assert feature_flag_cli in ret.stdout + + +def test_project_feature_flag_cli_get(gitlab_cli, project, feature_flag_cli): + cmd = [ + "project-feature-flag", + "get", + "--project-id", + str(project.id), + "--name", + feature_flag_cli, + ] + ret = gitlab_cli(cmd) + assert ret.success + assert feature_flag_cli in ret.stdout + + +def test_project_feature_flag_cli_update(gitlab_cli, project, feature_flag_cli): + cmd = [ + "project-feature-flag", + "update", + "--project-id", + str(project.id), + "--name", + feature_flag_cli, + "--active", + "false", + ] + ret = gitlab_cli(cmd) + assert ret.success + + cmd = [ + "-o", + "json", + "project-feature-flag", + "get", + "--project-id", + str(project.id), + "--name", + feature_flag_cli, + ] + ret = gitlab_cli(cmd) + assert ret.success + data = json.loads(ret.stdout) + assert data["active"] is False From 3e2385a8995f3c9a18eb97cd53206e5ce04b06b7 Mon Sep 17 00:00:00 2001 From: amimas Date: Mon, 16 Feb 2026 15:08:28 -0500 Subject: [PATCH 02/10] refactor(api): use JsonAttribute for feature flag strategies Add `JsonAttribute` to `gitlab.types` to handle JSON parsing for CLI arguments. Update `ProjectFeatureFlagManager` to use `JsonAttribute` for the `strategies` attribute, replacing custom `create` and `update` overrides. --- gitlab/types.py | 9 +++++ gitlab/v4/objects/feature_flags.py | 55 ++---------------------------- 2 files changed, 11 insertions(+), 53 deletions(-) diff --git a/gitlab/types.py b/gitlab/types.py index d0e8d3952..d77fa9be9 100644 --- a/gitlab/types.py +++ b/gitlab/types.py @@ -1,6 +1,7 @@ from __future__ import annotations import dataclasses +import json from typing import Any, TYPE_CHECKING @@ -49,6 +50,14 @@ def get_for_api(self, *, key: str) -> tuple[str, Any]: return (key, self._value) +class JsonAttribute(GitlabAttribute): + def set_from_cli(self, cli_value: str) -> None: + if not cli_value.strip(): + self._value = None + else: + self._value = json.loads(cli_value) + + class _ListArrayAttribute(GitlabAttribute): """Helper class to support `list` / `array` types.""" diff --git a/gitlab/v4/objects/feature_flags.py b/gitlab/v4/objects/feature_flags.py index f3fd13f88..6af5d4a90 100644 --- a/gitlab/v4/objects/feature_flags.py +++ b/gitlab/v4/objects/feature_flags.py @@ -5,9 +5,7 @@ from __future__ import annotations -import json -from typing import Any - +from gitlab import types from gitlab.base import RESTObject from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin from gitlab.types import RequiredOptional @@ -28,53 +26,4 @@ class ProjectFeatureFlagManager(CRUDMixin[ProjectFeatureFlag]): ) _update_attrs = RequiredOptional(optional=("description", "active", "strategies")) _list_filters = ("scope",) - - def create( - self, data: dict[str, Any] | None = None, **kwargs: Any - ) -> ProjectFeatureFlag: - """Create a new object. - - Args: - data: Parameters to send to the server to create the - resource - **kwargs: Extra options to send to the server (e.g. sudo) - - Returns: - A new instance of the managed object class build with - the data sent by the server - """ - # Handle strategies being passed as a JSON string (e.g. from CLI) - if "strategies" in kwargs and isinstance(kwargs["strategies"], str): - kwargs["strategies"] = json.loads(kwargs["strategies"]) - if data and "strategies" in data and isinstance(data["strategies"], str): - data["strategies"] = json.loads(data["strategies"]) - - return super().create(data, **kwargs) - - def update( - self, - id: str | int | None = None, - new_data: dict[str, Any] | None = None, - **kwargs: Any, - ) -> dict[str, Any]: - """Update an object on the server. - - Args: - id: ID of the object to update (can be None if not required) - new_data: the update data for the object - **kwargs: Extra options to send to the server (e.g. sudo) - - Returns: - The new object data (*not* a RESTObject) - """ - # Handle strategies being passed as a JSON string (e.g. from CLI) - if "strategies" in kwargs and isinstance(kwargs["strategies"], str): - kwargs["strategies"] = json.loads(kwargs["strategies"]) - if ( - new_data - and "strategies" in new_data - and isinstance(new_data["strategies"], str) - ): - new_data["strategies"] = json.loads(new_data["strategies"]) - - return super().update(id, new_data, **kwargs) + _types = {"strategies": types.JsonAttribute} From 9a9322dfc508fcb36caa849f16439cd7f8e33737 Mon Sep 17 00:00:00 2001 From: amimas Date: Sun, 8 Mar 2026 14:26:17 -0400 Subject: [PATCH 03/10] feat(api): add support for project feature flag user lists This adds support for managing user lists for project feature flags. It introduces the `ProjectFeatureFlagUserList` object and manager, and exposes it via `project.feature_flags_user_lists`. New type `CommaSeparatedStringAttribute` is added to handle comma-separated string values in API requests. --- gitlab/types.py | 10 ++ gitlab/utils.py | 4 +- gitlab/v4/objects/__init__.py | 1 + gitlab/v4/objects/feature_flag_user_lists.py | 27 ++++ gitlab/v4/objects/projects.py | 2 + .../test_project_feature_flag_user_lists.py | 56 ++++++++ ...est_cli_project_feature_flag_user_lists.py | 120 ++++++++++++++++++ 7 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 gitlab/v4/objects/feature_flag_user_lists.py create mode 100644 tests/functional/api/test_project_feature_flag_user_lists.py create mode 100644 tests/functional/cli/test_cli_project_feature_flag_user_lists.py diff --git a/gitlab/types.py b/gitlab/types.py index d77fa9be9..fd5cc315f 100644 --- a/gitlab/types.py +++ b/gitlab/types.py @@ -96,6 +96,16 @@ class CommaSeparatedListAttribute(_ListArrayAttribute): into a CSV""" +class CommaSeparatedStringAttribute(_ListArrayAttribute): + """ + For values which are sent to the server as a Comma Separated Values (CSV) string. + Unlike CommaSeparatedListAttribute, this type ensures the value is converted + to a string even in JSON bodies (POST/PUT requests). + """ + + transform_in_post = True + + class LowercaseStringAttribute(GitlabAttribute): def get_for_api(self, *, key: str) -> tuple[str, str]: return (key, str(self._value).lower()) diff --git a/gitlab/utils.py b/gitlab/utils.py index cf1b5b7b0..a81c820f1 100644 --- a/gitlab/utils.py +++ b/gitlab/utils.py @@ -198,7 +198,9 @@ def _transform_types( files[attr_name] = (key, data.pop(attr_name)) continue - if not transform_data: + if not transform_data and not getattr( + gitlab_attribute, "transform_in_post", False + ): continue if isinstance(gitlab_attribute, types.GitlabAttribute): diff --git a/gitlab/v4/objects/__init__.py b/gitlab/v4/objects/__init__.py index 84fb3651f..460297df7 100644 --- a/gitlab/v4/objects/__init__.py +++ b/gitlab/v4/objects/__init__.py @@ -24,6 +24,7 @@ from .epics import * from .events import * from .export_import import * +from .feature_flag_user_lists import * from .feature_flags import * from .features import * from .files import * diff --git a/gitlab/v4/objects/feature_flag_user_lists.py b/gitlab/v4/objects/feature_flag_user_lists.py new file mode 100644 index 000000000..4ba4be95c --- /dev/null +++ b/gitlab/v4/objects/feature_flag_user_lists.py @@ -0,0 +1,27 @@ +""" +GitLab API: +https://docs.gitlab.com/ee/api/feature_flag_user_lists.html +""" + +from __future__ import annotations + +from gitlab import types +from gitlab.base import RESTObject +from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin +from gitlab.types import RequiredOptional + +__all__ = ["ProjectFeatureFlagUserList", "ProjectFeatureFlagUserListManager"] + + +class ProjectFeatureFlagUserList(SaveMixin, ObjectDeleteMixin, RESTObject): + _id_attr = "iid" + + +class ProjectFeatureFlagUserListManager(CRUDMixin[ProjectFeatureFlagUserList]): + _path = "/projects/{project_id}/feature_flags_user_lists" + _obj_cls = ProjectFeatureFlagUserList + _from_parent_attrs = {"project_id": "id"} + _create_attrs = RequiredOptional(required=("name", "user_xids")) + _update_attrs = RequiredOptional(optional=("name", "user_xids")) + _list_filters = ("search",) + _types = {"user_xids": types.CommaSeparatedStringAttribute} diff --git a/gitlab/v4/objects/projects.py b/gitlab/v4/objects/projects.py index 6f865b410..22975ff9f 100644 --- a/gitlab/v4/objects/projects.py +++ b/gitlab/v4/objects/projects.py @@ -49,6 +49,7 @@ ) from .events import ProjectEventManager # noqa: F401 from .export_import import ProjectExportManager, ProjectImportManager # noqa: F401 +from .feature_flag_user_lists import ProjectFeatureFlagUserListManager # noqa: F401 from .feature_flags import ProjectFeatureFlagManager # noqa: F401 from .files import ProjectFileManager # noqa: F401 from .hooks import ProjectHookManager # noqa: F401 @@ -203,6 +204,7 @@ class Project( events: ProjectEventManager exports: ProjectExportManager feature_flags: ProjectFeatureFlagManager + feature_flags_user_lists: ProjectFeatureFlagUserListManager files: ProjectFileManager forks: ProjectForkManager generic_packages: GenericPackageManager diff --git a/tests/functional/api/test_project_feature_flag_user_lists.py b/tests/functional/api/test_project_feature_flag_user_lists.py new file mode 100644 index 000000000..ecf7972f9 --- /dev/null +++ b/tests/functional/api/test_project_feature_flag_user_lists.py @@ -0,0 +1,56 @@ +import pytest + +from gitlab import exceptions + + +@pytest.fixture +def user_list(project, user): + user_list = project.feature_flags_user_lists.create( + {"name": "test_user_list", "user_xids": str(user.id)} + ) + yield user_list + try: + user_list.delete() + except exceptions.GitlabDeleteError: + pass + + +def test_create_user_list(project, user): + user_list = project.feature_flags_user_lists.create( + {"name": "created_user_list", "user_xids": str(user.id)} + ) + assert user_list.name == "created_user_list" + assert str(user.id) in user_list.user_xids + user_list.delete() + + +def test_list_user_lists(project, user_list): + ff_user_lists = project.feature_flags_user_lists.list() + assert len(ff_user_lists) >= 1 + assert user_list.iid in [ff_user.iid for ff_user in ff_user_lists] + + +def test_get_user_list(project, user_list, user): + retrieved_list = project.feature_flags_user_lists.get(user_list.iid) + assert retrieved_list.name == user_list.name + assert str(user.id) in retrieved_list.user_xids + + +def test_update_user_list(project, user_list): + user_list.name = "updated_user_list" + user_list.save() + + updated_list = project.feature_flags_user_lists.get(user_list.iid) + assert updated_list.name == "updated_user_list" + + +def test_delete_user_list(project, user_list): + user_list.delete() + with pytest.raises(exceptions.GitlabGetError): + project.feature_flags_user_lists.get(user_list.iid) + + +def test_search_user_list(project, user_list): + ff_user_lists = project.feature_flags_user_lists.list(search=user_list.name) + assert len(ff_user_lists) >= 1 + assert user_list.iid in [ff_user.iid for ff_user in ff_user_lists] diff --git a/tests/functional/cli/test_cli_project_feature_flag_user_lists.py b/tests/functional/cli/test_cli_project_feature_flag_user_lists.py new file mode 100644 index 000000000..96e48379e --- /dev/null +++ b/tests/functional/cli/test_cli_project_feature_flag_user_lists.py @@ -0,0 +1,120 @@ +import json + +import pytest + + +@pytest.fixture +def user_list_cli(gitlab_cli, project, user): + list_name = "cli_test_list_fixture" + cmd = [ + "-o", + "json", + "project-feature-flag-user-list", + "create", + "--project-id", + str(project.id), + "--name", + list_name, + "--user-xids", + str(user.id), + ] + ret = gitlab_cli(cmd) + data = json.loads(ret.stdout) + iid = str(data["iid"]) + + yield iid + + try: + cmd = [ + "project-feature-flag-user-list", + "delete", + "--project-id", + str(project.id), + "--iid", + iid, + ] + gitlab_cli(cmd) + except Exception: + pass + + +def test_project_feature_flag_user_list_cli_create_delete(gitlab_cli, project, user): + list_name = "cli_test_list_create" + + cmd = [ + "-o", + "json", + "project-feature-flag-user-list", + "create", + "--project-id", + str(project.id), + "--name", + list_name, + "--user-xids", + str(user.id), + ] + ret = gitlab_cli(cmd) + assert ret.success + data = json.loads(ret.stdout) + assert data["name"] == list_name + assert str(user.id) in data["user_xids"] + iid = str(data["iid"]) + + cmd = [ + "project-feature-flag-user-list", + "delete", + "--project-id", + str(project.id), + "--iid", + iid, + ] + ret = gitlab_cli(cmd) + assert ret.success + + +def test_project_feature_flag_user_list_cli_list(gitlab_cli, project, user_list_cli): + cmd = [ + "-o", + "json", + "project-feature-flag-user-list", + "list", + "--project-id", + str(project.id), + ] + ret = gitlab_cli(cmd) + assert ret.success + data = json.loads(ret.stdout) + assert any(item["name"] == "cli_test_list_fixture" for item in data) + + +def test_project_feature_flag_user_list_cli_get(gitlab_cli, project, user_list_cli): + cmd = [ + "-o", + "json", + "project-feature-flag-user-list", + "get", + "--project-id", + str(project.id), + "--iid", + user_list_cli, + ] + ret = gitlab_cli(cmd) + assert ret.success + data = json.loads(ret.stdout) + assert data["name"] == "cli_test_list_fixture" + + +def test_project_feature_flag_user_list_cli_update(gitlab_cli, project, user_list_cli): + new_name = "cli_updated_list" + cmd = [ + "project-feature-flag-user-list", + "update", + "--project-id", + str(project.id), + "--iid", + user_list_cli, + "--name", + new_name, + ] + ret = gitlab_cli(cmd) + assert ret.success From 0915bfcea999e983c0faa53efeeb7df58c3e57b1 Mon Sep 17 00:00:00 2001 From: amimas Date: Sun, 8 Mar 2026 15:34:22 -0400 Subject: [PATCH 04/10] test(api): validate feature flag strategy and scope deletion Add functional tests to ensure that feature flag strategies and scopes can be removed using the `_destroy` flag in the update payload, as supported by the GitLab API. --- .../api/test_project_feature_flags.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/functional/api/test_project_feature_flags.py b/tests/functional/api/test_project_feature_flags.py index e6a9b32f6..d1f82cb40 100644 --- a/tests/functional/api/test_project_feature_flags.py +++ b/tests/functional/api/test_project_feature_flags.py @@ -56,3 +56,52 @@ def test_delete_feature_flag(project, feature_flag): feature_flag.delete() with pytest.raises(exceptions.GitlabGetError): project.feature_flags.get(feature_flag.name) + + +def test_delete_feature_flag_strategy(project, feature_flag): + strategies = [ + {"name": "default", "parameters": {}}, + {"name": "userWithId", "parameters": {"userIds": "user1"}}, + ] + feature_flag.strategies = strategies + feature_flag.save() + + feature_flag = project.feature_flags.get(feature_flag.name) + assert len(feature_flag.strategies) == 2 + + # Remove strategy using _destroy + strategies = feature_flag.strategies + for strategy in strategies: + if strategy["name"] == "userWithId": + strategy["_destroy"] = True + feature_flag.save() + + feature_flag = project.feature_flags.get(feature_flag.name) + assert len(feature_flag.strategies) == 1 + assert feature_flag.strategies[0]["name"] == "default" + + +def test_delete_feature_flag_scope(project, feature_flag): + strategies = [ + { + "name": "default", + "parameters": {}, + "scopes": [{"environment_scope": "*"}, {"environment_scope": "production"}], + } + ] + feature_flag.strategies = strategies + feature_flag.save() + + feature_flag = project.feature_flags.get(feature_flag.name) + assert len(feature_flag.strategies[0]["scopes"]) == 2 + + # Remove scope using _destroy + strategies = feature_flag.strategies + for scope in strategies[0]["scopes"]: + if scope["environment_scope"] == "production": + scope["_destroy"] = True + feature_flag.save() + + feature_flag = project.feature_flags.get(feature_flag.name) + assert len(feature_flag.strategies[0]["scopes"]) == 1 + assert feature_flag.strategies[0]["scopes"][0]["environment_scope"] == "*" From 02e553515becc845c162d6296f616da486c420af Mon Sep 17 00:00:00 2001 From: amimas Date: Sun, 8 Mar 2026 23:36:27 -0400 Subject: [PATCH 05/10] feat(api): enable feature flag renaming via save() The `ProjectFeatureFlag` object uses `name` as its ID attribute. Previously, modifying the `name` of a flag object and calling `save()` would fail because the library would attempt to send a PUT request to the *new* name, which does not yet exist, resulting in a 404 error. This change overrides the `save()` method in `ProjectFeatureFlag` to correctly handle renaming. It now detects when the `name` attribute has been modified and uses the original name for the API request URL, while sending the new name in the request body. This makes renaming feature flags more intuitive and consistent with the behavior of other objects in the library. --- gitlab/v4/objects/feature_flags.py | 52 ++++++++++++++++++- .../api/test_project_feature_flags.py | 20 +++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/gitlab/v4/objects/feature_flags.py b/gitlab/v4/objects/feature_flags.py index 6af5d4a90..51142a336 100644 --- a/gitlab/v4/objects/feature_flags.py +++ b/gitlab/v4/objects/feature_flags.py @@ -5,7 +5,9 @@ from __future__ import annotations -from gitlab import types +from typing import Any + +from gitlab import types, utils from gitlab.base import RESTObject from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin from gitlab.types import RequiredOptional @@ -15,6 +17,50 @@ class ProjectFeatureFlag(SaveMixin, ObjectDeleteMixin, RESTObject): _id_attr = "name" + manager: ProjectFeatureFlagManager + + def _get_save_url_id(self) -> str | int | None: + """Get the ID used to construct the API URL for the save operation. + + For renames, this must be the *original* name of the flag. For other + updates, it is the current name. + """ + if self._id_attr in self._updated_attrs: + # If the name is being changed, use the original name for the URL. + obj_id = self._attrs.get(self._id_attr) + if isinstance(obj_id, str): + return utils.EncodedId(obj_id) + return obj_id + return self.encoded_id + + def save(self, **kwargs: Any) -> dict[str, Any] | None: + """Save the changes made to the object to the server. + + The object is updated to match what the server returns. + + This method overrides the default ``save()`` method to handle renaming + feature flags. When the name is modified, the API requires the original + name in the URL to identify the resource, while the new name is sent + in the request body. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Returns: + The new object data (*not* a RESTObject) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabUpdateError: If the server cannot perform the request + """ + updated_data = self._get_updated_data() + if not updated_data: + return None + + obj_id = self._get_save_url_id() + server_data = self.manager.update(obj_id, updated_data, **kwargs) + self._update_attrs(server_data) + return server_data class ProjectFeatureFlagManager(CRUDMixin[ProjectFeatureFlag]): @@ -24,6 +70,8 @@ class ProjectFeatureFlagManager(CRUDMixin[ProjectFeatureFlag]): _create_attrs = RequiredOptional( required=("name",), optional=("version", "description", "active", "strategies") ) - _update_attrs = RequiredOptional(optional=("description", "active", "strategies")) + _update_attrs = RequiredOptional( + optional=("name", "description", "active", "strategies") + ) _list_filters = ("scope",) _types = {"strategies": types.JsonAttribute} diff --git a/tests/functional/api/test_project_feature_flags.py b/tests/functional/api/test_project_feature_flags.py index d1f82cb40..4fbe3ba3b 100644 --- a/tests/functional/api/test_project_feature_flags.py +++ b/tests/functional/api/test_project_feature_flags.py @@ -52,6 +52,26 @@ def test_update_feature_flag(project, feature_flag): assert updated_flag.active is False +def test_rename_feature_flag(project, feature_flag): + # Rename via save() + new_name = "renamed_flag" + feature_flag.name = new_name + feature_flag.save() + + updated_flag = project.feature_flags.get(new_name) + assert updated_flag.name == new_name + + # Rename via update() + newer_name = "renamed_flag_2" + project.feature_flags.update(new_name, {"name": newer_name}) + + updated_flag_2 = project.feature_flags.get(newer_name) + assert updated_flag_2.name == newer_name + + # Update the fixture object so teardown can delete the correct flag + feature_flag.name = newer_name + + def test_delete_feature_flag(project, feature_flag): feature_flag.delete() with pytest.raises(exceptions.GitlabGetError): From 07d98beb07a1a37efecb4cc0647e3f0df448feed Mon Sep 17 00:00:00 2001 From: amimas Date: Mon, 9 Mar 2026 15:58:47 -0400 Subject: [PATCH 06/10] docs(api): add documentation for project feature flags This adds the documentation for Project Feature Flags and Feature Flag User Lists. It includes: - New documentation pages for Feature Flags and User Lists. - Updates to the Project documentation to reference the new managers. --- docs/api-objects.rst | 2 + .../project_feature_flag_user_lists.rst | 51 +++++++++++++++ docs/gl_objects/project_feature_flags.rst | 63 +++++++++++++++++++ docs/gl_objects/projects.rst | 38 +++++++++++ 4 files changed, 154 insertions(+) create mode 100644 docs/gl_objects/project_feature_flag_user_lists.rst create mode 100644 docs/gl_objects/project_feature_flags.rst diff --git a/docs/api-objects.rst b/docs/api-objects.rst index 7218518b1..ae6327b8c 100644 --- a/docs/api-objects.rst +++ b/docs/api-objects.rst @@ -49,6 +49,8 @@ API examples gl_objects/pipelines_and_jobs gl_objects/projects gl_objects/project_access_tokens + gl_objects/project_feature_flags + gl_objects/project_feature_flag_user_lists gl_objects/protected_branches gl_objects/protected_container_repositories gl_objects/protected_environments diff --git a/docs/gl_objects/project_feature_flag_user_lists.rst b/docs/gl_objects/project_feature_flag_user_lists.rst new file mode 100644 index 000000000..64a600777 --- /dev/null +++ b/docs/gl_objects/project_feature_flag_user_lists.rst @@ -0,0 +1,51 @@ +####################### +Project Feature Flag User Lists +####################### + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectFeatureFlagUserList` + + :class:`gitlab.v4.objects.ProjectFeatureFlagUserListManager` + + :attr:`gitlab.v4.objects.Project.feature_flags_user_lists` + +* GitLab API: https://docs.gitlab.com/ee/api/feature_flag_user_lists.html + +Examples +-------- + +List user lists:: + + user_lists = project.feature_flags_user_lists.list() + +Get a user list:: + + user_list = project.feature_flags_user_lists.get(list_iid) + +Create a user list:: + + user_list = project.feature_flags_user_lists.create({ + 'name': 'my_user_list', + 'user_xids': 'user1,user2,user3' + }) + +Update a user list:: + + user_list.name = 'updated_list_name' + user_list.user_xids = 'user1,user2' + user_list.save() + +Delete a user list:: + + user_list.delete() + +Search for a user list:: + + user_lists = project.feature_flags_user_lists.list(search='my_list') + +See also +-------- + +* :doc:`project_feature_flags` diff --git a/docs/gl_objects/project_feature_flags.rst b/docs/gl_objects/project_feature_flags.rst new file mode 100644 index 000000000..2d1d411ba --- /dev/null +++ b/docs/gl_objects/project_feature_flags.rst @@ -0,0 +1,63 @@ +##################### +Project Feature Flags +##################### + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectFeatureFlag` + + :class:`gitlab.v4.objects.ProjectFeatureFlagManager` + + :attr:`gitlab.v4.objects.Project.feature_flags` + +* GitLab API: https://docs.gitlab.com/ee/api/feature_flags.html + +Examples +-------- + +List feature flags:: + + flags = project.feature_flags.list() + +Get a feature flag:: + + flag = project.feature_flags.get('my_feature_flag') + +Create a feature flag:: + + flag = project.feature_flags.create({'name': 'my_feature_flag', 'version': 'new_version_flag'}) + +Create a feature flag with strategies:: + + flag = project.feature_flags.create({ + 'name': 'my_complex_flag', + 'version': 'new_version_flag', + 'strategies': [{ + 'name': 'userWithId', + 'parameters': {'userIds': 'user1,user2'} + }] + }) + +Update a feature flag:: + + flag.description = 'Updated description' + flag.save() + +Rename a feature flag:: + + # You can rename a flag by changing its name attribute and calling save() + flag.name = 'new_flag_name' + flag.save() + + # Alternatively, you can use the manager's update method + project.feature_flags.update('old_flag_name', {'name': 'new_flag_name'}) + +Delete a feature flag:: + + flag.delete() + +See also +-------- + +* :doc:`project_feature_flag_user_lists` diff --git a/docs/gl_objects/projects.rst b/docs/gl_objects/projects.rst index 8305a6b0b..acefb0806 100644 --- a/docs/gl_objects/projects.rst +++ b/docs/gl_objects/projects.rst @@ -409,6 +409,44 @@ Search projects by custom attribute:: project.customattributes.set('type', 'internal') gl.projects.list(custom_attributes={'type': 'internal'}, get_all=True) +Project feature flags +===================== + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectFeatureFlag` + + :class:`gitlab.v4.objects.ProjectFeatureFlagManager` + + :attr:`gitlab.v4.objects.Project.feature_flags` + +* GitLab API: https://docs.gitlab.com/ee/api/feature_flags.html + +Examples +-------- + +See :doc:`project_feature_flags`. + +Project feature flag user lists +=============================== + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectFeatureFlagUserList` + + :class:`gitlab.v4.objects.ProjectFeatureFlagUserListManager` + + :attr:`gitlab.v4.objects.Project.feature_flags_user_lists` + +* GitLab API: https://docs.gitlab.com/ee/api/feature_flag_user_lists.html + +Examples +-------- + +See :doc:`project_feature_flag_user_lists`. + Project files ============= From 7e287d2d3b448c615748517840dd079da53740e9 Mon Sep 17 00:00:00 2001 From: amimas Date: Mon, 9 Mar 2026 16:09:37 -0400 Subject: [PATCH 07/10] docs: clarify development feature flag documentation The existing documentation for `features.rst` covered the API for managing GitLab's internal development feature flags, which requires administrator access. This was easily confused with the newly added project-level feature flags API. To prevent ambiguity for users, this commit refactors the documentation by: - Renaming `features.rst` to `gitlab_features.rst`. - Updating the title and adding a note to clarify its specific, admin-only purpose. - Adding a cross-reference to the new project-level feature flag documentation. --- docs/api-objects.rst | 2 +- .../{features.rst => gitlab_features.rst} | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) rename docs/gl_objects/{features.rst => gitlab_features.rst} (58%) diff --git a/docs/api-objects.rst b/docs/api-objects.rst index ae6327b8c..7107107c2 100644 --- a/docs/api-objects.rst +++ b/docs/api-objects.rst @@ -24,7 +24,7 @@ API examples gl_objects/environments gl_objects/events gl_objects/epics - gl_objects/features + gl_objects/gitlab_features gl_objects/geo_nodes gl_objects/groups gl_objects/group_access_tokens diff --git a/docs/gl_objects/features.rst b/docs/gl_objects/gitlab_features.rst similarity index 58% rename from docs/gl_objects/features.rst rename to docs/gl_objects/gitlab_features.rst index d7552041d..a2e32a40e 100644 --- a/docs/gl_objects/features.rst +++ b/docs/gl_objects/gitlab_features.rst @@ -1,6 +1,11 @@ -############## -Features flags -############## +################################ +GitLab Development Feature Flags +################################ + +.. note:: + + This API is for managing GitLab's internal development feature flags and requires administrator access. + For project-level feature flags, see :doc:`project_feature_flags`. Reference --------- @@ -11,7 +16,7 @@ Reference + :class:`gitlab.v4.objects.FeatureManager` + :attr:`gitlab.Gitlab.features` -* GitLab API: https://docs.gitlab.com/api/features +* GitLab API: https://docs.gitlab.com/ee/api/features.html Examples -------- @@ -29,4 +34,4 @@ Create or set a feature:: Delete a feature:: - feature.delete() + feature.delete() \ No newline at end of file From 522c25efac1ceb8a2f0a3e81f57a64ee26a83e15 Mon Sep 17 00:00:00 2001 From: amimas Date: Mon, 9 Mar 2026 18:31:05 -0400 Subject: [PATCH 08/10] test(api): add unit tests for feature flags and types This adds unit tests for the new logic introduced with the feature flags API. - Verifies the custom `save()` method in `ProjectFeatureFlag` correctly handles renaming by using the old name in the URL and the new name in the request body. - Verifies the `CommaSeparatedStringAttribute` correctly converts a Python list to a comma-separated string for API calls. - Verifies the `JsonAttribute` correctly handles JSON string parsing. --- .../test_project_feature_flag_user_lists.py | 36 ++++++++++++++ .../objects/test_project_feature_flags.py | 48 +++++++++++++++++++ tests/unit/test_types.py | 37 ++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 tests/unit/objects/test_project_feature_flag_user_lists.py create mode 100644 tests/unit/objects/test_project_feature_flags.py diff --git a/tests/unit/objects/test_project_feature_flag_user_lists.py b/tests/unit/objects/test_project_feature_flag_user_lists.py new file mode 100644 index 000000000..482217453 --- /dev/null +++ b/tests/unit/objects/test_project_feature_flag_user_lists.py @@ -0,0 +1,36 @@ +""" +Unit tests for Project Feature Flag User Lists. +""" + +import pytest +import responses + +from gitlab import Gitlab + + +@pytest.fixture +def project(): + gl = Gitlab("http://localhost", private_token="private_token", api_version="4") + return gl.projects.get(1, lazy=True) + + +def test_create_user_list_with_list_conversion(project): + """ + Verify that passing a list of integers for user_xids is converted + to a comma-separated string in the API payload. + """ + with responses.RequestsMock() as rs: + rs.add( + responses.POST, + "http://localhost/api/v4/projects/1/feature_flags_user_lists", + json={"iid": 1, "name": "list", "user_xids": "1,2,3"}, + status=201, + ) + + project.feature_flags_user_lists.create( + {"name": "list", "user_xids": [1, 2, 3]} + ) + + assert len(rs.calls) == 1 + # Verify that the list [1, 2, 3] was converted to "1,2,3" in the JSON body + assert b'"user_xids": "1,2,3"' in rs.calls[0].request.body diff --git a/tests/unit/objects/test_project_feature_flags.py b/tests/unit/objects/test_project_feature_flags.py new file mode 100644 index 000000000..5d6c6f558 --- /dev/null +++ b/tests/unit/objects/test_project_feature_flags.py @@ -0,0 +1,48 @@ +""" +Unit tests for Project Feature Flags. +""" + +import pytest +import responses + +from gitlab import Gitlab +from gitlab.v4.objects import ProjectFeatureFlag + + +@pytest.fixture +def project(): + gl = Gitlab("http://localhost", private_token="private_token", api_version="4") + return gl.projects.get(1, lazy=True) + + +def test_feature_flag_rename(project): + """ + Verify that renaming a feature flag uses the old name in the URL + and the new name in the payload. + """ + flag_content = {"name": "old_name", "version": "new_version_flag", "active": True} + flag = ProjectFeatureFlag(project.feature_flags, flag_content) + + # Simulate fetching from API (populates _attrs) + flag._attrs = flag_content.copy() + flag._updated_attrs = {} + + # Rename locally + flag.name = "new_name" + + with responses.RequestsMock() as rs: + rs.add( + responses.PUT, + "http://localhost/api/v4/projects/1/feature_flags/old_name", + json={"name": "new_name", "version": "new_version_flag", "active": True}, + status=200, + ) + + flag.save() + + assert len(rs.calls) == 1 + # URL should use the old name (ID) + assert rs.calls[0].request.url.endswith("/feature_flags/old_name") + # Body should contain the new name + assert b'"name": "new_name"' in rs.calls[0].request.body + assert flag.name == "new_name" diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 351f6ca34..1140fb4ed 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -122,3 +122,40 @@ def test_csv_string_attribute_get_for_api_from_int_list(): def test_lowercase_string_attribute_get_for_api(): o = types.LowercaseStringAttribute("FOO") assert o.get_for_api(key="spam") == ("spam", "foo") + + +# JSONAttribute tests +def test_json_attribute() -> None: + attr = types.JsonAttribute() + + attr.set_from_cli('{"key": "value"}') + assert attr.get() == {"key": "value"} + + attr.set_from_cli(" ") + assert attr.get() is None + + +# CommaSeparatedStringAttribute tests +def test_comma_separated_string_attribute() -> None: + # Test with list of integers + attr = types.CommaSeparatedStringAttribute([1, 2, 3]) + assert attr.get_for_api(key="ids") == ("ids", "1,2,3") + + # Test with list of strings + attr = types.CommaSeparatedStringAttribute(["a", "b"]) + assert attr.get_for_api(key="names") == ("names", "a,b") + + # Test with string value (should be preserved) + attr = types.CommaSeparatedStringAttribute("1,2,3") + assert attr.get_for_api(key="ids") == ("ids", "1,2,3") + + # Test CLI setting + attr = types.CommaSeparatedStringAttribute() + attr.set_from_cli("1, 2, 3") + assert attr.get() == ["1", "2", "3"] + + attr.set_from_cli("") + assert attr.get() == [] + + # Verify transform_in_post is True + assert types.CommaSeparatedStringAttribute.transform_in_post is True From 8d21b07c96b0f48f8efdffe42996e2d7471e4109 Mon Sep 17 00:00:00 2001 From: amimas Date: Mon, 9 Mar 2026 20:16:07 -0400 Subject: [PATCH 09/10] fix(docs): correct title overline length The title overline in `docs/gl_objects/project_feature_flag_user_lists.rst` was shorter than the title text, causing a Sphinx warning. --- docs/gl_objects/project_feature_flag_user_lists.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/gl_objects/project_feature_flag_user_lists.rst b/docs/gl_objects/project_feature_flag_user_lists.rst index 64a600777..3a7de5cbe 100644 --- a/docs/gl_objects/project_feature_flag_user_lists.rst +++ b/docs/gl_objects/project_feature_flag_user_lists.rst @@ -1,6 +1,6 @@ -####################### +############################### Project Feature Flag User Lists -####################### +############################### Reference --------- From 977ab5cdd8e611208da6ec7787bd573ed59967f6 Mon Sep 17 00:00:00 2001 From: amimas Date: Mon, 9 Mar 2026 22:35:27 -0400 Subject: [PATCH 10/10] refactor(test): use shared project fixture in unit tests This commit refactors the unit tests for `ProjectFeatureFlag` and `ProjectFeatureFlagUserList` to use the shared `project` fixture from `conftest.py`. This change removes duplicated local fixture definitions, improving code consistency and maintainability, and addresses feedback from the pull request review. --- .../unit/objects/test_project_feature_flag_user_lists.py | 9 --------- tests/unit/objects/test_project_feature_flags.py | 8 -------- tests/unit/test_types.py | 2 +- 3 files changed, 1 insertion(+), 18 deletions(-) diff --git a/tests/unit/objects/test_project_feature_flag_user_lists.py b/tests/unit/objects/test_project_feature_flag_user_lists.py index 482217453..eba71502f 100644 --- a/tests/unit/objects/test_project_feature_flag_user_lists.py +++ b/tests/unit/objects/test_project_feature_flag_user_lists.py @@ -2,17 +2,8 @@ Unit tests for Project Feature Flag User Lists. """ -import pytest import responses -from gitlab import Gitlab - - -@pytest.fixture -def project(): - gl = Gitlab("http://localhost", private_token="private_token", api_version="4") - return gl.projects.get(1, lazy=True) - def test_create_user_list_with_list_conversion(project): """ diff --git a/tests/unit/objects/test_project_feature_flags.py b/tests/unit/objects/test_project_feature_flags.py index 5d6c6f558..64dc51473 100644 --- a/tests/unit/objects/test_project_feature_flags.py +++ b/tests/unit/objects/test_project_feature_flags.py @@ -2,19 +2,11 @@ Unit tests for Project Feature Flags. """ -import pytest import responses -from gitlab import Gitlab from gitlab.v4.objects import ProjectFeatureFlag -@pytest.fixture -def project(): - gl = Gitlab("http://localhost", private_token="private_token", api_version="4") - return gl.projects.get(1, lazy=True) - - def test_feature_flag_rename(project): """ Verify that renaming a feature flag uses the old name in the URL diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 1140fb4ed..53badc273 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -124,7 +124,7 @@ def test_lowercase_string_attribute_get_for_api(): assert o.get_for_api(key="spam") == ("spam", "foo") -# JSONAttribute tests +# JsonAttribute tests def test_json_attribute() -> None: attr = types.JsonAttribute()