From e6669f96d662d310109afa4a61fe8dabbd780a4e Mon Sep 17 00:00:00 2001 From: amimas Date: Mon, 16 Feb 2026 22:16:12 -0500 Subject: [PATCH 1/5] docs(testing): document passing pytest options during local development Added a section into the contributing docs to show how to pass options to `pytest`. This can be useful during local development for selecting specific tests. Running entire test suite is very time consuming. --- CONTRIBUTING.rst | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 9b07ada11..4da710499 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -194,6 +194,32 @@ To cleanup the environment delete the container: docker rm -f gitlab-test docker rm -f gitlab-runner-test +Pass options to ``pytest`` +-------------------------- + +Options to ``pytest`` can be passed by adding them after ``--`` when running ``tox``: + +.. code-block:: bash + + tox -e api_func_v4 -- . + +For example, you can use this to run a specific test. Running all tests can be time-consuming, +so this allows you to focus on just the tests relevant to your changes. You can do this by passing +the ``-k`` flag to ``pytest`` and setting a relevant expression to select the tests to run. For example: + +.. code-block:: bash + + # Run all API functional tests from the ``test_projects.py`` file: + tox -e api_func_v4 -- --keep-containers -k test_projects.py + + # Run only the ``test_get_project`` test method from the ``test_projects.py`` file: + tox -e api_func_v4 -- --keep-containers -k "test_projects.py and test_create_project" + + # The above will select all test methods start with ``test_create_project`` from the ``test_projects.py`` file. + # To select only the ``test_create_project`` method, you can exclude other methods by using the ``not`` operator: + tox -e api_func_v4 -- --keep-containers -k "test_projects.py and test_create_project and not test_create_project_" + + Rerunning failed CI workflows ----------------------------- From 691fd8f932a812020ac9ce127fcb1a9b75aea1c2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 02:16:20 +0000 Subject: [PATCH 2/5] chore(deps): update all non-major dependencies --- .github/workflows/test.yml | 4 ++-- .pre-commit-config.yaml | 2 +- requirements-test.txt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 07981c74e..388f66bbe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,7 +77,7 @@ jobs: TOXENV: ${{ matrix.toxenv }} run: tox -- --override-ini='log_cli=True' - name: Upload codecov coverage - uses: codecov/codecov-action@v5.5.2 + uses: codecov/codecov-action@v5.5.3 with: files: ./coverage.xml flags: ${{ matrix.toxenv }} @@ -100,7 +100,7 @@ jobs: TOXENV: cover run: tox - name: Upload codecov coverage - uses: codecov/codecov-action@v5.5.2 + uses: codecov/codecov-action@v5.5.3 with: files: ./coverage.xml flags: unit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2a70a9b3f..603222c30 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,6 +51,6 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/maxbrunet/pre-commit-renovate - rev: 43.76.3 + rev: 43.86.0 hooks: - id: renovate-config-validator diff --git a/requirements-test.txt b/requirements-test.txt index a6e9a82b1..2a91f37db 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,9 +1,9 @@ -r requirements.txt anyio==4.12.1 build==1.4.0 -coverage==7.13.4 +coverage==7.13.5 pytest-console-scripts==1.4.1 -pytest-cov==7.0.0 +pytest-cov==7.1.0 pytest-github-actions-annotate-failures==0.4.0 pytest==9.0.2 PyYaml==6.0.3 From 4fffe195094280094bf7f0d7c3c668adc4b2e3de Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 03:37:40 +0000 Subject: [PATCH 3/5] chore(deps): update pre-commit hook pycqa/isort to v8 --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 603222c30..73c726956 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pycqa/isort - rev: 7.0.0 + rev: 8.0.1 hooks: - id: isort - repo: https://github.com/pycqa/pylint From 8d76028a1ae3554527291dc98e6be041ff089ec5 Mon Sep 17 00:00:00 2001 From: Shehzad Qureshi Date: Thu, 19 Mar 2026 21:21:01 +0000 Subject: [PATCH 4/5] feat(projects): add optional parameter to set approval rule on all protected branches. --- gitlab/v4/objects/merge_request_approvals.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/gitlab/v4/objects/merge_request_approvals.py b/gitlab/v4/objects/merge_request_approvals.py index 6ca324ecf..3d2082b91 100644 --- a/gitlab/v4/objects/merge_request_approvals.py +++ b/gitlab/v4/objects/merge_request_approvals.py @@ -90,7 +90,13 @@ class ProjectApprovalRuleManager( _from_parent_attrs = {"project_id": "id"} _create_attrs = RequiredOptional( required=("name", "approvals_required"), - optional=("user_ids", "group_ids", "protected_branch_ids", "usernames"), + optional=( + "user_ids", + "group_ids", + "protected_branch_ids", + "usernames", + "applies_to_all_protected_branches", + ), ) From be68285793f35afc10a72b59da3fb24429631f54 Mon Sep 17 00:00:00 2001 From: amimas Date: Mon, 16 Feb 2026 09:50:56 -0500 Subject: [PATCH 5/5] feat(api): add support for project feature flags and feature flag user lists Add new API objects for managing project feature flags and feature flag user lists, including CRUD operations, CLI support, and renaming via save(). Introduce JsonAttribute type for handling JSON fields in request bodies (e.g., feature flag strategies). Closes #3350 --- docs/api-objects.rst | 4 +- .../{features.rst => gitlab_features.rst} | 13 +- .../project_feature_flag_user_lists.rst | 51 +++++ docs/gl_objects/project_feature_flags.rst | 63 ++++++ docs/gl_objects/projects.rst | 38 ++++ gitlab/types.py | 40 +++- gitlab/utils.py | 10 +- gitlab/v4/objects/__init__.py | 2 + gitlab/v4/objects/feature_flag_user_lists.py | 27 +++ gitlab/v4/objects/feature_flags.py | 106 +++++++++ gitlab/v4/objects/projects.py | 4 + .../test_project_feature_flag_user_lists.py | 56 +++++ .../api/test_project_feature_flags.py | 127 +++++++++++ ...est_cli_project_feature_flag_user_lists.py | 120 +++++++++++ .../cli/test_cli_project_feature_flags.py | 203 ++++++++++++++++++ .../test_project_feature_flag_user_lists.py | 30 +++ .../objects/test_project_feature_flags.py | 35 +++ tests/unit/test_types.py | 39 +++- 18 files changed, 958 insertions(+), 10 deletions(-) rename docs/gl_objects/{features.rst => gitlab_features.rst} (64%) create mode 100644 docs/gl_objects/project_feature_flag_user_lists.rst create mode 100644 docs/gl_objects/project_feature_flags.rst create mode 100644 gitlab/v4/objects/feature_flag_user_lists.py create mode 100644 gitlab/v4/objects/feature_flags.py create mode 100644 tests/functional/api/test_project_feature_flag_user_lists.py create mode 100644 tests/functional/api/test_project_feature_flags.py create mode 100644 tests/functional/cli/test_cli_project_feature_flag_user_lists.py create mode 100644 tests/functional/cli/test_cli_project_feature_flags.py 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/docs/api-objects.rst b/docs/api-objects.rst index 7218518b1..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 @@ -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/features.rst b/docs/gl_objects/gitlab_features.rst similarity index 64% rename from docs/gl_objects/features.rst rename to docs/gl_objects/gitlab_features.rst index d7552041d..7df506b09 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 --------- @@ -29,4 +34,4 @@ Create or set a feature:: Delete a feature:: - feature.delete() + feature.delete() \ No newline at end of file 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..9e25ceb44 --- /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/api/feature_flag_user_lists + +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..c9630f3e6 --- /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/api/feature_flags + +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..824914cef 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/api/feature_flags + +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/api/feature_flag_user_lists + +Examples +-------- + +See :doc:`project_feature_flag_user_lists`. + Project files ============= diff --git a/gitlab/types.py b/gitlab/types.py index d0e8d3952..ae0aba707 100644 --- a/gitlab/types.py +++ b/gitlab/types.py @@ -1,8 +1,11 @@ from __future__ import annotations import dataclasses +import json from typing import Any, TYPE_CHECKING +from gitlab import exceptions + @dataclasses.dataclass(frozen=True) class RequiredOptional: @@ -36,6 +39,13 @@ def validate_attrs( class GitlabAttribute: + # Used in utils._transform_types() to decide if we should call get_for_api() + # on the attribute when transform_data is False (e.g. for POST/PUT/PATCH). + # + # This allows us to force transformation of data even when sending JSON bodies, + # which is useful for types like CommaSeparatedStringAttribute. + transform_in_body = False + def __init__(self, value: Any = None) -> None: self._value = value @@ -49,6 +59,16 @@ 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: + try: + self._value = json.loads(cli_value) + except (ValueError, TypeError) as e: + raise exceptions.GitlabParsingError( + f"Could not parse JSON data: {e}" + ) from e + + class _ListArrayAttribute(GitlabAttribute): """Helper class to support `list` / `array` types.""" @@ -82,9 +102,23 @@ def get_for_api(self, *, key: str) -> tuple[str, Any]: class CommaSeparatedListAttribute(_ListArrayAttribute): - """For values which are sent to the server as a Comma Separated Values - (CSV) string. We allow them to be specified as a list and we convert it - into a CSV""" + """ + For values which are sent to the server as a Comma Separated Values (CSV) string + in query parameters (GET), but as a list/array in JSON bodies (POST/PUT). + """ + + +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). + """ + + # Used in utils._transform_types() to ensure the value is converted to a string + # via get_for_api() even when transform_data is False (e.g. for POST/PUT/PATCH). + # This is needed because some APIs require a CSV string instead of a JSON array. + transform_in_body = True class LowercaseStringAttribute(GitlabAttribute): diff --git a/gitlab/utils.py b/gitlab/utils.py index cf1b5b7b0..49a280278 100644 --- a/gitlab/utils.py +++ b/gitlab/utils.py @@ -198,7 +198,15 @@ def _transform_types( files[attr_name] = (key, data.pop(attr_name)) continue - if not transform_data: + # If transform_data is False, it means we are preparing data for a JSON body + # (POST/PUT/PATCH). In this case, we normally skip transformation because + # most types (like ArrayAttribute) only need transformation for query + # parameters (GET). + # + # However, some types (like CommaSeparatedStringAttribute) need to be + # transformed even in JSON bodies (e.g. converting a list to a CSV string). + # The 'transform_in_body' flag on the attribute class controls this behavior. + if not transform_data and not gitlab_attribute.transform_in_body: continue if isinstance(gitlab_attribute, types.GitlabAttribute): diff --git a/gitlab/v4/objects/__init__.py b/gitlab/v4/objects/__init__.py index cc2ffeb52..460297df7 100644 --- a/gitlab/v4/objects/__init__.py +++ b/gitlab/v4/objects/__init__.py @@ -24,6 +24,8 @@ 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 * from .geo_nodes 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..50861715a --- /dev/null +++ b/gitlab/v4/objects/feature_flag_user_lists.py @@ -0,0 +1,27 @@ +""" +GitLab API: +https://docs.gitlab.com/api/feature_flag_user_lists +""" + +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/feature_flags.py b/gitlab/v4/objects/feature_flags.py new file mode 100644 index 000000000..b34283b6c --- /dev/null +++ b/gitlab/v4/objects/feature_flags.py @@ -0,0 +1,106 @@ +""" +GitLab API: +https://docs.gitlab.com/api/feature_flags +""" + +from __future__ import annotations + +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 + +__all__ = ["ProjectFeatureFlag", "ProjectFeatureFlagManager"] + + +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. + + This is the standard method to use when updating a feature flag object + that you have already retrieved. + + It is overridden here to correctly handle renaming. When `name` is + changed, the API requires the *original* name in the URL, and this + method provides it. + + 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]): + _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( + # new_name is used for renaming via CLI and mapped to 'name' in update() + optional=("name", "new_name", "description", "active", "strategies") + ) + _list_filters = ("scope",) + _types = {"strategies": types.JsonAttribute} + + def update( + self, + id: str | int | None = None, + new_data: dict[str, Any] | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Update a Project Feature Flag. + + This is a lower-level method called by `ProjectFeatureFlag.save()` and + is also used directly by the CLI. + + The `new_name` parameter is a special case to support renaming via the + CLI (`--new-name`). It is converted to the `name` parameter that the + GitLab API expects in the request body. + + Args: + id: The current name of the feature flag. + new_data: The dictionary of attributes to update. + **kwargs: Extra options to send to the server (e.g. sudo) + """ + # Avoid mutating the caller-provided new_data dict by working on a copy. + data = dict(new_data or {}) + # When used via CLI, we have 'new_name' to distinguish from the ID 'name'. + # When used via .save(), the object passes 'name' directly in new_data. + if "new_name" in data: + data["name"] = data.pop("new_name") + return super().update(id, data, **kwargs) diff --git a/gitlab/v4/objects/projects.py b/gitlab/v4/objects/projects.py index 751ac4c1f..22975ff9f 100644 --- a/gitlab/v4/objects/projects.py +++ b/gitlab/v4/objects/projects.py @@ -49,6 +49,8 @@ ) 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 from .integrations import ProjectIntegrationManager, ProjectServiceManager # noqa: F401 @@ -201,6 +203,8 @@ class Project( environments: ProjectEnvironmentManager 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/api/test_project_feature_flags.py b/tests/functional/api/test_project_feature_flags.py new file mode 100644 index 000000000..f555dc3d4 --- /dev/null +++ b/tests/functional/api/test_project_feature_flags.py @@ -0,0 +1,127 @@ +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_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): + 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() + + updated_feature_flag = project.feature_flags.get(feature_flag.name) + assert len(updated_feature_flag.strategies) == 2 + + # Remove strategy using _destroy + updated_strategies = updated_feature_flag.strategies + for strategy in updated_strategies: + if strategy["name"] == "userWithId": + strategy["_destroy"] = True + updated_feature_flag.save() + + updated_feature_flag = project.feature_flags.get(feature_flag.name) + assert len(updated_feature_flag.strategies) == 1 + assert updated_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() + + updated_feature_flag = project.feature_flags.get(feature_flag.name) + assert len(updated_feature_flag.strategies[0]["scopes"]) == 2 + + # Remove scope using _destroy + updated_strategies = updated_feature_flag.strategies + for scope in updated_strategies[0]["scopes"]: + if scope["environment_scope"] == "production": + scope["_destroy"] = True + updated_feature_flag.save() + + updated_feature_flag = project.feature_flags.get(feature_flag.name) + assert len(updated_feature_flag.strategies[0]["scopes"]) == 1 + assert updated_feature_flag.strategies[0]["scopes"][0]["environment_scope"] == "*" 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 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..559970f26 --- /dev/null +++ b/tests/functional/cli/test_cli_project_feature_flags.py @@ -0,0 +1,203 @@ +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 + + +def test_project_feature_flag_cli_create_with_malformed_strategies(gitlab_cli, project): + flag_name = "test_flag_cli_malformed_strategies" + strategies_json = '[{"name": "userWithId"' # Malformed JSON + + cmd = [ + "project-feature-flag", + "create", + "--project-id", + str(project.id), + "--name", + flag_name, + "--strategies", + strategies_json, + ] + ret = gitlab_cli(cmd) + assert not ret.success + assert "Could not parse JSON data" in ret.stderr + + +def test_project_feature_flag_cli_rename(gitlab_cli, project, feature_flag_cli): + new_name = "cli_renamed_flag" + cmd = [ + "project-feature-flag", + "update", + "--project-id", + str(project.id), + "--name", + feature_flag_cli, + "--new-name", + new_name, + ] + ret = gitlab_cli(cmd) + assert ret.success + + cmd = [ + "-o", + "json", + "project-feature-flag", + "get", + "--project-id", + str(project.id), + "--name", + new_name, + ] + ret = gitlab_cli(cmd) + assert ret.success + data = json.loads(ret.stdout) + assert data["name"] == new_name + # Cleanup renamed flag + cmd = [ + "project-feature-flag", + "delete", + "--project-id", + str(project.id), + "--name", + new_name, + ] + gitlab_cli(cmd) 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..92a9fd13e --- /dev/null +++ b/tests/unit/objects/test_project_feature_flag_user_lists.py @@ -0,0 +1,30 @@ +""" +Unit tests for Project Feature Flag User Lists. +""" + +import responses + + +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, + match=[ + responses.matchers.json_params_matcher( + {"name": "list", "user_xids": "1,2,3"} + ) + ], + ) + + project.feature_flags_user_lists.create( + {"name": "list", "user_xids": [1, 2, 3]} + ) + + assert len(rs.calls) == 1 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..a74fb7ee6 --- /dev/null +++ b/tests/unit/objects/test_project_feature_flags.py @@ -0,0 +1,35 @@ +""" +Unit tests for Project Feature Flags. +""" + +import responses + +from gitlab.v4.objects import ProjectFeatureFlag + + +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) + + # 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, + match=[responses.matchers.json_params_matcher({"name": "new_name"})], + ) + + 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") + assert flag.name == "new_name" diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 351f6ca34..0c1b6b8ed 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -1,6 +1,6 @@ import pytest -from gitlab import types +from gitlab import exceptions, types class TestRequiredOptional: @@ -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"} + + with pytest.raises(exceptions.GitlabParsingError): + attr.set_from_cli(" ") + + +# 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_body is True + assert types.CommaSeparatedStringAttribute.transform_in_body is True