From 90133a218bab4f3c076af8594074fcdb7fc9e11a Mon Sep 17 00:00:00 2001 From: sahilnagel Date: Fri, 10 Apr 2026 15:02:42 +0530 Subject: [PATCH] feat: Code References Added GitLab as a VCS provider LiveReview Pre-Commit Check: skipped LiveReview Pre-Commit Check: skipped --- .../migrations/0003_add_vcs_provider.py | 22 +++++ api/projects/code_references/serializers.py | 2 + api/projects/code_references/services.py | 6 ++ api/projects/code_references/types.py | 2 + .../unit/features/test_unit_features_views.py | 4 + ..._unit_projects_code_references_services.py | 50 ++++++++++ ...est_unit_projects_code_references_views.py | 99 +++++++++++++++++++ 7 files changed, 185 insertions(+) create mode 100644 api/projects/code_references/migrations/0003_add_vcs_provider.py diff --git a/api/projects/code_references/migrations/0003_add_vcs_provider.py b/api/projects/code_references/migrations/0003_add_vcs_provider.py new file mode 100644 index 000000000000..8bec6d6ea679 --- /dev/null +++ b/api/projects/code_references/migrations/0003_add_vcs_provider.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.13 on 2026-04-10 08:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("code_references", "0002_add_project_repo_created_index"), + ] + + operations = [ + migrations.AlterField( + model_name="featureflagcodereferencesscan", + name="vcs_provider", + field=models.CharField( + choices=[("github", "GitHub"), ("gitlab", "GitLab")], + default="github", + max_length=50, + ), + ), + ] diff --git a/api/projects/code_references/serializers.py b/api/projects/code_references/serializers.py index 78dfd413f7e8..113eb58b791c 100644 --- a/api/projects/code_references/serializers.py +++ b/api/projects/code_references/serializers.py @@ -41,6 +41,7 @@ class Meta: "repository_url", "project", "revision", + "vcs_provider", "code_references", ] read_only_fields = [ @@ -67,3 +68,4 @@ class FeatureFlagCodeReferencesRepositoryCountSerializer( count = serializers.IntegerField() last_successful_repository_scanned_at = serializers.DateTimeField() last_feature_found_at = serializers.DateTimeField(allow_null=True) + vcs_provider = serializers.ChoiceField(choices=VCSProvider.choices) diff --git a/api/projects/code_references/services.py b/api/projects/code_references/services.py index a0d572bce444..487d42ba0467 100644 --- a/api/projects/code_references/services.py +++ b/api/projects/code_references/services.py @@ -84,6 +84,7 @@ def annotate_feature_queryset_with_code_references_summary( count=F("count"), last_successful_repository_scanned_at=F("created_at"), last_feature_found_at=F("last_feature_found_at"), + vcs_provider=F("vcs_provider"), ), ) ) @@ -162,6 +163,11 @@ def _get_permalink( f"{repository_url}/", f"blob/{revision}/{file_path}#L{line_number}", ) + case VCSProvider.GITLAB: + return urljoin( + f"{repository_url}/", + f"-/blob/{revision}/{file_path}#L{line_number}", + ) raise NotImplementedError( # pragma: no cover f"Permalink generation for {provider} is not implemented." ) diff --git a/api/projects/code_references/types.py b/api/projects/code_references/types.py index 346dde597742..ee24001ca29e 100644 --- a/api/projects/code_references/types.py +++ b/api/projects/code_references/types.py @@ -7,6 +7,7 @@ class VCSProvider(TextChoices): GITHUB = "github", "GitHub" + GITLAB = "gitlab", "GitLab" class JSONCodeReference(TypedDict): @@ -37,5 +38,6 @@ class FeatureFlagCodeReferencesRepositorySummary: class CodeReferencesRepositoryCount: repository_url: str count: int + vcs_provider: str last_successful_repository_scanned_at: datetime last_feature_found_at: datetime | None diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index d4e95211f581..b640af8d1c17 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -3611,6 +3611,7 @@ def test_list_features__with_code_references__returns_counts( project=project, repository_url="https://gitlab.flagsmith.com/frontend/", revision="frontend-1", + vcs_provider="gitlab", code_references=[ { "feature_name": feature.name, @@ -3636,6 +3637,7 @@ def test_list_features__with_code_references__returns_counts( project=project, repository_url="https://gitlab.flagsmith.com/frontend/", revision="frontend-2", + vcs_provider="gitlab", code_references=[ { "feature_name": feature.name, @@ -3658,12 +3660,14 @@ def test_list_features__with_code_references__returns_counts( assert response.json()["results"][0]["code_references_counts"] == [ { "repository_url": "https://github.flagsmith.com/backend/", + "vcs_provider": "github", "count": 0, "last_successful_repository_scanned_at": "2099-01-02T14:00:00+00:00", "last_feature_found_at": "2099-01-01T13:00:00+00:00", }, { "repository_url": "https://gitlab.flagsmith.com/frontend/", + "vcs_provider": "gitlab", "count": 2, "last_successful_repository_scanned_at": "2099-01-02T14:00:00+00:00", "last_feature_found_at": "2099-01-02T14:00:00+00:00", diff --git a/api/tests/unit/projects/code_references/test_unit_projects_code_references_services.py b/api/tests/unit/projects/code_references/test_unit_projects_code_references_services.py index 8e2e70645f02..e933031385fa 100644 --- a/api/tests/unit/projects/code_references/test_unit_projects_code_references_services.py +++ b/api/tests/unit/projects/code_references/test_unit_projects_code_references_services.py @@ -52,3 +52,53 @@ def test_get_permalink__private_github_repository__returns_valid_url( assert result == ( "https://github.flagsmith.com/flagsmith/backend/blob/revision-hash/path/to/file.py#L10" ) + + +@pytest.mark.parametrize( + "repository_url", + [ + "https://gitlab.com/Flagsmith/flagsmith", + "https://gitlab.com/Flagsmith/flagsmith/", # with trailing slash + ], +) +def test_get_permalink__public_gitlab_repository__returns_valid_url( + repository_url: str, +) -> None: + # Given / When + result = _get_permalink( + provider=VCSProvider.GITLAB, + repository_url=repository_url, + revision="revision-hash", + file_path="path/to/file.py", + line_number=10, + ) + + # Then + assert result == ( + "https://gitlab.com/Flagsmith/flagsmith/-/blob/revision-hash/path/to/file.py#L10" + ) + + +@pytest.mark.parametrize( + "repository_url", + [ + "https://gitlab.internal.flagsmith.com/flagsmith/backend", + "https://gitlab.internal.flagsmith.com/flagsmith/backend/", # with trailing slash + ], +) +def test_get_permalink__private_gitlab_repository__returns_valid_url( + repository_url: str, +) -> None: + # Given / When + result = _get_permalink( + provider=VCSProvider.GITLAB, + repository_url=repository_url, + revision="revision-hash", + file_path="path/to/file.py", + line_number=10, + ) + + # Then + assert result == ( + "https://gitlab.internal.flagsmith.com/flagsmith/backend/-/blob/revision-hash/path/to/file.py#L10" + ) diff --git a/api/tests/unit/projects/code_references/test_unit_projects_code_references_views.py b/api/tests/unit/projects/code_references/test_unit_projects_code_references_views.py index 55b408d7ec73..17166995c869 100644 --- a/api/tests/unit/projects/code_references/test_unit_projects_code_references_views.py +++ b/api/tests/unit/projects/code_references/test_unit_projects_code_references_views.py @@ -45,6 +45,8 @@ def test_create_code_reference__valid_payload__returns_201_with_accepted_referen assert len(response.data["code_references"]) == 3 assert response.data["project"] == project.pk assert response.data["created_at"] == "2025-04-14T12:30:00Z" + assert response.data["vcs_provider"] == "github" + assert FeatureFlagCodeReferencesScan.objects.get().vcs_provider == "github" assert FeatureFlagCodeReferencesScan.objects.get().code_references == [ { "feature_name": "feature-1", @@ -381,3 +383,100 @@ def test_get_feature_code_references__feature_not_found__returns_404( # Then assert response.status_code == 404 assert response.data["detail"] == "No Feature matches the given query." + + +def test_create_code_reference__with_gitlab_provider__returns_201( + admin_client_new: APIClient, + project: Project, +) -> None: + # Given / When + response = admin_client_new.post( + f"/api/v1/projects/{project.pk}/code-references/", + data={ + "repository_url": "https://gitlab.com/flagsmith", + "revision": "revision-hash", + "vcs_provider": "gitlab", # Explicitly testing GitLab + "code_references": [ + { + "feature_name": "feature-1", + "file_path": "path/to/file1.py", + "line_number": 10, + }, + ], + }, + format="json", + ) + + # Then + assert response.status_code == 201 + assert response.data["vcs_provider"] == "gitlab" + assert FeatureFlagCodeReferencesScan.objects.get().vcs_provider == "gitlab" + + +def test_create_code_reference__with_invalid_provider__returns_400( + admin_client_new: APIClient, + project: Project, +) -> None: + # Given / When + response = admin_client_new.post( + f"/api/v1/projects/{project.pk}/code-references/", + data={ + "repository_url": "https://bitbucket.org/flagsmith", + "revision": "revision-hash", + "vcs_provider": "bitbucket", # Invalid provider + "code_references": [ + { + "feature_name": "feature-1", + "file_path": "path/to/file1.py", + "line_number": 10, + }, + ], + }, + format="json", + ) + + # Then + assert response.status_code == 400 + assert "vcs_provider" in response.data + assert not FeatureFlagCodeReferencesScan.objects.exists() + + +def test_get_feature_code_references__gitlab_scan__returns_expected_permalinks( + admin_client_new: APIClient, + feature: Feature, + project: Project, +) -> None: + # Given + with freezegun.freeze_time("2099-01-01T10:00:00-0300"): + FeatureFlagCodeReferencesScan.objects.create( + project=project, + repository_url="https://gitlab.com/flagsmith/backend", + revision="backend-1", + vcs_provider="gitlab", + code_references=[ + { + "feature_name": feature.name, + "file_path": "backend/file1.py", + "line_number": 20, + }, + ], + ) + + # When + response = admin_client_new.get( + f"/api/v1/projects/{project.pk}/features/{feature.pk}/code-references/", + ) + + # Then + assert response.status_code == 200 + + response_data = response.json() + assert len(response_data) == 1 + assert response_data[0]["vcs_provider"] == "gitlab" + + # Assert the permalink uses the GitLab /-/blob/ format + permalink = response_data[0]["code_references"][0]["permalink"] + assert ( + permalink + == "https://gitlab.com/flagsmith/backend/-/blob/backend-1/backend/file1.py#L20" + )