diff --git a/api/features/serializers.py b/api/features/serializers.py index 7e9bd68f1304..af69f1c4d38c 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -24,6 +24,9 @@ from integrations.github.constants import GitHubEventType from integrations.github.github import call_github_task from metadata.serializers import MetadataSerializer, MetadataSerializerMixin +from projects.code_references.serializers import ( + FeatureFlagCodeReferencesRepositoryCountSerializer, +) from projects.models import Project from users.serializers import ( UserIdsSerializer, @@ -327,8 +330,16 @@ def get_last_modified_in_current_environment( class FeatureSerializerWithMetadata(MetadataSerializerMixin, CreateFeatureSerializer): metadata = MetadataSerializer(required=False, many=True) + code_references_counts = FeatureFlagCodeReferencesRepositoryCountSerializer( + many=True, + read_only=True, + ) + class Meta(CreateFeatureSerializer.Meta): - fields = CreateFeatureSerializer.Meta.fields + ("metadata",) # type: ignore[assignment] + fields = CreateFeatureSerializer.Meta.fields + ( # type: ignore[assignment] + "metadata", + "code_references_counts", + ) def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: attrs = super().validate(attrs) diff --git a/api/features/views.py b/api/features/views.py index ef55ffe82978..5cf5e04d1539 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -39,6 +39,9 @@ NestedEnvironmentPermissions, ) from features.value_types import BOOLEAN, INTEGER, STRING +from projects.code_references.services import ( + annotate_feature_queryset_with_code_references_summary, +) from projects.models import Project from users.models import FFAdminUser, UserPermissionGroup from webhooks.webhooks import WebhookEventType @@ -143,6 +146,8 @@ def get_queryset(self): # type: ignore[no-untyped-def] query_serializer.is_valid(raise_exception=True) query_data = query_serializer.validated_data + queryset = annotate_feature_queryset_with_code_references_summary(queryset) + queryset = self._filter_queryset(queryset) if environment_id := query_data.get("environment"): diff --git a/api/projects/code_references/constants.py b/api/projects/code_references/constants.py new file mode 100644 index 000000000000..8d7377ca8d68 --- /dev/null +++ b/api/projects/code_references/constants.py @@ -0,0 +1,5 @@ +# TODO: Implement history cleanup? +FEATURE_FLAG_CODE_REFERENCES_RETENTION_DAYS = 30 + +# Linux maximum file path length, as per limits.h/PATH_MAX +MAX_FILE_PATH_LENGTH = 4096 diff --git a/api/projects/code_references/migrations/0001_code_references.py b/api/projects/code_references/migrations/0001_code_references.py index 478eff2f1aa6..2e21758cb0ab 100644 --- a/api/projects/code_references/migrations/0001_code_references.py +++ b/api/projects/code_references/migrations/0001_code_references.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.22 on 2025-08-14 15:12 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/api/projects/code_references/models.py b/api/projects/code_references/models.py index b8abb7c58b50..5c1d7b3f0e48 100644 --- a/api/projects/code_references/models.py +++ b/api/projects/code_references/models.py @@ -1,14 +1,13 @@ from django.db import models +from projects.code_references.types import JSONCodeReference, VCSProvider + class FeatureFlagCodeReferencesScan(models.Model): """ A scan of feature flag code references in a repository """ - class Providers(models.TextChoices): - GITHUB = "github", "GitHub" - project = models.ForeignKey( "projects.Project", on_delete=models.CASCADE, @@ -20,11 +19,11 @@ class Providers(models.TextChoices): vcs_provider = models.CharField( max_length=50, - choices=Providers.choices, - default=Providers.GITHUB, # TODO: Remove when adding other providers + choices=VCSProvider.choices, + default=VCSProvider.GITHUB, # TODO: Remove when adding other providers ) revision = models.CharField(max_length=100) - code_references = models.JSONField(default=list) + code_references = models.JSONField[list[JSONCodeReference]](default=list) created_at = models.DateTimeField(auto_now_add=True, db_index=True) diff --git a/api/projects/code_references/permissions.py b/api/projects/code_references/permissions.py index c9c08b9b8ef0..409dff1008db 100644 --- a/api/projects/code_references/permissions.py +++ b/api/projects/code_references/permissions.py @@ -7,7 +7,7 @@ from users.models import FFAdminUser -class SubmitFeatureFlagCodeReferences(IsAuthenticated): +class _BaseCodeReferencePermission(IsAuthenticated): def has_permission(self, request: Request, view: APIView) -> bool: if not super().has_permission(request, view): return False @@ -17,3 +17,11 @@ def has_permission(self, request: Request, view: APIView) -> bool: project = Project.objects.get(id=view.kwargs["project_pk"]) return request.user.has_project_permission(VIEW_PROJECT, project) + + +class SubmitFeatureFlagCodeReferences(_BaseCodeReferencePermission): + pass + + +class ViewFeatureFlagCodeReferences(_BaseCodeReferencePermission): + pass diff --git a/api/projects/code_references/serializers.py b/api/projects/code_references/serializers.py index f4be377c52e1..78dfd413f7e8 100644 --- a/api/projects/code_references/serializers.py +++ b/api/projects/code_references/serializers.py @@ -1,26 +1,36 @@ -from typing import TypedDict - from rest_framework import serializers +from projects.code_references.constants import MAX_FILE_PATH_LENGTH from projects.code_references.models import FeatureFlagCodeReferencesScan +from projects.code_references.types import ( + CodeReference, + CodeReferencesRepositoryCount, + FeatureFlagCodeReferencesRepositorySummary, + VCSProvider, +) -class _CodeReference(TypedDict): - feature_name: str - file_path: str - line_number: int +class _BaseCodeReferenceSerializer(serializers.Serializer[CodeReference]): + file_path = serializers.CharField(max_length=MAX_FILE_PATH_LENGTH) + line_number = serializers.IntegerField(min_value=1) -class _CodeReferenceSerializer(serializers.Serializer[_CodeReference]): +class _CodeReferenceSubmitSerializer(_BaseCodeReferenceSerializer): feature_name = serializers.CharField(max_length=100) - file_path = serializers.CharField(max_length=200) - line_number = serializers.IntegerField(min_value=1) + + +class _CodeReferenceDetailSerializer(_BaseCodeReferenceSerializer): + scanned_at = serializers.DateTimeField() + vcs_provider = serializers.ChoiceField(choices=VCSProvider.choices) + repository_url = serializers.URLField() + revision = serializers.CharField() + permalink = serializers.URLField() class FeatureFlagCodeReferencesScanSerializer( serializers.ModelSerializer[FeatureFlagCodeReferencesScan], ): - code_references = _CodeReferenceSerializer( + code_references = _CodeReferenceSubmitSerializer( many=True, required=True, allow_empty=False ) @@ -37,3 +47,23 @@ class Meta: "created_at", "project", ] + + +class FeatureFlagCodeReferencesRepositorySummarySerializer( + serializers.Serializer[FeatureFlagCodeReferencesRepositorySummary], +): + repository_url = serializers.URLField() + vcs_provider = serializers.ChoiceField(choices=VCSProvider.choices) + revision = serializers.CharField() + last_successful_repository_scanned_at = serializers.DateTimeField() + last_feature_found_at = serializers.DateTimeField(allow_null=True) + code_references = _CodeReferenceDetailSerializer(many=True) + + +class FeatureFlagCodeReferencesRepositoryCountSerializer( + serializers.Serializer[CodeReferencesRepositoryCount], +): + repository_url = serializers.URLField() + count = serializers.IntegerField() + last_successful_repository_scanned_at = serializers.DateTimeField() + last_feature_found_at = serializers.DateTimeField(allow_null=True) diff --git a/api/projects/code_references/services.py b/api/projects/code_references/services.py new file mode 100644 index 000000000000..f8d95739d57e --- /dev/null +++ b/api/projects/code_references/services.py @@ -0,0 +1,158 @@ +from datetime import timedelta +from urllib.parse import urljoin + +from django.contrib.postgres.expressions import ArraySubquery +from django.db.models import BooleanField, F, Func, OuterRef, QuerySet, Subquery, Value +from django.db.models.functions import JSONObject +from django.utils import timezone + +from features.models import Feature +from projects.code_references.constants import ( + FEATURE_FLAG_CODE_REFERENCES_RETENTION_DAYS, +) +from projects.code_references.models import FeatureFlagCodeReferencesScan +from projects.code_references.types import ( + CodeReference, + FeatureFlagCodeReferencesRepositorySummary, + VCSProvider, +) + + +def annotate_feature_queryset_with_code_references_summary( + queryset: QuerySet[Feature], +) -> QuerySet[Feature]: + """Extend feature objects with a `code_references_counts` + + NOTE: This adds compatibility with `CodeReferenceRepositoryCountSerializer` + while preventing N+1 queries from the serializer. + """ + history_delta = timedelta(days=FEATURE_FLAG_CODE_REFERENCES_RETENTION_DAYS) + last_feature_found_at = ( + FeatureFlagCodeReferencesScan.objects.annotate( + feature_name=OuterRef("feature_name"), + contains_feature_name=Func( + F("code_references"), + Value("$[*] ? (@.feature_name == $feature_name)"), + JSONObject(feature_name=F("feature_name")), + function="jsonb_path_exists", + output_field=BooleanField(), + ), + ) + .filter( + project=OuterRef("project_id"), + created_at__gte=timezone.now() - history_delta, + repository_url=OuterRef("repository_url"), + contains_feature_name=True, + ) + .values("created_at") + .order_by("-created_at")[:1] + ) + counts_by_repository = ( + FeatureFlagCodeReferencesScan.objects + # Count code references from JSON matching the feature name + .annotate( + feature_name=OuterRef("name"), + last_feature_found_at=Subquery(last_feature_found_at), + count=Func( + Func( + F("code_references"), + Value("$[*] ? (@.feature_name == $feature_name)"), + JSONObject(feature_name=F("feature_name")), + function="jsonb_path_query_array", + ), + function="jsonb_array_length", + ), + ) + # Only from the latest scans of each repository + .filter( + created_at__gte=timezone.now() - history_delta, + project_id=OuterRef("project_id"), + ) + .order_by("repository_url", "-created_at") + .distinct("repository_url") + .values( + json=JSONObject( + repository_url=F("repository_url"), + count=F("count"), + last_successful_repository_scanned_at=F("created_at"), + last_feature_found_at=F("last_feature_found_at"), + ), + ) + ) + + return queryset.annotate( + code_references_counts=ArraySubquery(counts_by_repository), + ) + + +def get_code_references_for_feature_flag( + feature: Feature, +) -> list[FeatureFlagCodeReferencesRepositorySummary]: + """Obtain a summary of latest code references for a feature + + Only query from the latest scans of each repository_url. This is used to + populate `FeatureFlagCodeReferencesSerializer`. + """ + history_delta = timedelta(days=FEATURE_FLAG_CODE_REFERENCES_RETENTION_DAYS) + last_feature_found_at = ( + FeatureFlagCodeReferencesScan.objects.filter( + project=feature.project, + created_at__gte=timezone.now() - history_delta, + repository_url=OuterRef("repository_url"), + code_references__contains=[{"feature_name": feature.name}], + ) + .values("created_at") + .order_by("-created_at")[:1] + ) + + last_scans_of_each_repository = ( + FeatureFlagCodeReferencesScan.objects.filter(project=feature.project) + .annotate(last_feature_found_at=Subquery(last_feature_found_at)) + .order_by("repository_url", "-created_at") + .distinct("repository_url") + ) + + return [ + FeatureFlagCodeReferencesRepositorySummary( + repository_url=scan.repository_url, + vcs_provider=VCSProvider(scan.vcs_provider), + revision=scan.revision, + last_successful_repository_scanned_at=scan.created_at, + last_feature_found_at=scan.last_feature_found_at, + code_references=[ + CodeReference( + feature_name=feature.name, + file_path=reference["file_path"], + line_number=reference["line_number"], + permalink=_get_permalink( + provider=VCSProvider(scan.vcs_provider), + repository_url=scan.repository_url, + revision=scan.revision, + file_path=reference["file_path"], + line_number=reference["line_number"], + ), + ) + for reference in scan.code_references + if reference["feature_name"] == feature.name + ], + ) + for scan in last_scans_of_each_repository + ] + + +def _get_permalink( + provider: VCSProvider, + repository_url: str, + revision: str, + file_path: str, + line_number: int, +) -> str: + """Generate a permalink for the code reference.""" + match provider: + case VCSProvider.GITHUB: + return urljoin( + 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 new file mode 100644 index 000000000000..346dde597742 --- /dev/null +++ b/api/projects/code_references/types.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import TypedDict + +from django.db.models import TextChoices + + +class VCSProvider(TextChoices): + GITHUB = "github", "GitHub" + + +class JSONCodeReference(TypedDict): + feature_name: str + file_path: str + line_number: int + + +@dataclass +class CodeReference: + feature_name: str + file_path: str + line_number: int + permalink: str + + +@dataclass +class FeatureFlagCodeReferencesRepositorySummary: + repository_url: str + vcs_provider: VCSProvider + revision: str + last_successful_repository_scanned_at: datetime + last_feature_found_at: datetime | None + code_references: list[CodeReference] + + +@dataclass +class CodeReferencesRepositoryCount: + repository_url: str + count: int + last_successful_repository_scanned_at: datetime + last_feature_found_at: datetime | None diff --git a/api/projects/code_references/urls.py b/api/projects/code_references/urls.py index 85a56fb4a8cd..e7482f3fef3b 100644 --- a/api/projects/code_references/urls.py +++ b/api/projects/code_references/urls.py @@ -10,4 +10,9 @@ views.FeatureFlagCodeReferencesScanCreateAPIView.as_view(), name="code_reference_create", ), + path( + "projects//features//code-references/", + views.FeatureFlagCodeReferenceDetailAPIView.as_view(), + name="feature_code_reference_detail", + ), ] diff --git a/api/projects/code_references/views.py b/api/projects/code_references/views.py index 2bbdf75f7fde..a9c1b7a478e2 100644 --- a/api/projects/code_references/views.py +++ b/api/projects/code_references/views.py @@ -1,8 +1,22 @@ -from rest_framework import generics +from typing import Any +from django.shortcuts import get_object_or_404 +from rest_framework import generics, response + +from features.models import Feature from projects.code_references.models import FeatureFlagCodeReferencesScan -from projects.code_references.permissions import SubmitFeatureFlagCodeReferences -from projects.code_references.serializers import FeatureFlagCodeReferencesScanSerializer +from projects.code_references.permissions import ( + SubmitFeatureFlagCodeReferences, + ViewFeatureFlagCodeReferences, +) +from projects.code_references.serializers import ( + FeatureFlagCodeReferencesRepositorySummarySerializer, + FeatureFlagCodeReferencesScanSerializer, +) +from projects.code_references.services import get_code_references_for_feature_flag +from projects.code_references.types import ( + FeatureFlagCodeReferencesRepositorySummary, +) class FeatureFlagCodeReferencesScanCreateAPIView( @@ -19,3 +33,22 @@ def perform_create( # type: ignore[override] self, serializer: FeatureFlagCodeReferencesScanSerializer ) -> None: serializer.save(project_id=self.kwargs["project_pk"]) + + +class FeatureFlagCodeReferenceDetailAPIView( + generics.RetrieveAPIView[FeatureFlagCodeReferencesRepositorySummary], # type: ignore[type-var] +): + """ + API view to retrieve code references for a specific feature in a project + """ + + serializer_class = FeatureFlagCodeReferencesRepositorySummarySerializer + permission_classes = [ViewFeatureFlagCodeReferences] + + def get(self, *args: Any, **kwargs: Any) -> response.Response: + feature = get_object_or_404( + Feature, + pk=self.kwargs["feature_pk"], + project_id=self.kwargs["project_pk"], + ) + return response.Response(get_code_references_for_feature_flag(feature)) diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index d80431b9c1dd..53a33b6fee5d 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -51,6 +51,7 @@ from metadata.models import MetadataModelField from organisations.models import Organisation, OrganisationRole from permissions.models import PermissionModel +from projects.code_references.models import FeatureFlagCodeReferencesScan from projects.models import Project, UserProjectPermission from projects.tags.models import Tag from segments.models import Segment @@ -3343,6 +3344,92 @@ def test_list_features_with_filter_by_search_value_boolean( assert response.data["results"][0]["name"] == feature2.name +def test_FeatureViewSet_list__includes_code_references_counts( + staff_client: APIClient, + project: Project, + feature: Feature, + with_project_permissions: WithProjectPermissionsCallable, + environment: Environment, +) -> None: + # Given + with_project_permissions([VIEW_PROJECT]) # type: ignore[call-arg] + with freeze_time("2099-01-01T10:00:00-0300"): + FeatureFlagCodeReferencesScan.objects.create( + project=project, + repository_url="https://github.flagsmith.com/backend/", + revision="backend-1", + code_references=[ + { + "feature_name": feature.name, + "file_path": "path/to/file.py", + "line_number": 42, + }, + ], + ) + FeatureFlagCodeReferencesScan.objects.create( + project=project, + repository_url="https://gitlab.flagsmith.com/frontend/", + revision="frontend-1", + code_references=[ + { + "feature_name": feature.name, + "file_path": "path/to/file.js", + "line_number": 23, + }, + ], + ) + with freeze_time("2099-01-02T11:00:00-0300"): + FeatureFlagCodeReferencesScan.objects.create( + project=project, + repository_url="https://github.flagsmith.com/backend/", + revision="backend-2", + code_references=[ + { + "feature_name": f"Another {feature.name}", + "file_path": "path/to/another/file.py", + "line_number": 11, + }, + ], + ) + FeatureFlagCodeReferencesScan.objects.create( + project=project, + repository_url="https://gitlab.flagsmith.com/frontend/", + revision="frontend-2", + code_references=[ + { + "feature_name": feature.name, + "file_path": "path/to/file.js", + "line_number": 23, + }, + { + "feature_name": feature.name, + "file_path": "path/to/another/file.js", + "line_number": 50, + }, + ], + ) + + # When + response = staff_client.get(f"/api/v1/projects/{project.pk}/features/") + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["results"][0]["code_references_counts"] == [ + { + "repository_url": "https://github.flagsmith.com/backend/", + "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/", + "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", + }, + ] + + def test_simple_feature_state_returns_only_latest_versions( staff_client: APIClient, staff_user: FFAdminUser, 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 a9b1c617ea14..d7fa09b78dff 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 @@ -2,6 +2,7 @@ from common.projects.permissions import VIEW_PROJECT from rest_framework.test import APIClient +from features.models import Feature from projects.code_references.models import FeatureFlagCodeReferencesScan from projects.models import Project from tests.types import WithProjectPermissionsCallable @@ -95,7 +96,7 @@ def test_CodeReferenceCreateAPIView__responds_401_when_not_authenticated( assert not FeatureFlagCodeReferencesScan.objects.exists() -def test_CodeReferenceCreateAPIView__responds_400_when_invalid_data( +def test_CodeReferenceCreateAPIView__responds_400_when_missing_field( project: Project, staff_client: APIClient, with_project_permissions: WithProjectPermissionsCallable, @@ -123,6 +124,245 @@ def test_CodeReferenceCreateAPIView__responds_400_when_invalid_data( # Then assert response.status_code == 400 assert response.data == { - "code_references": [{"line_number": ["This field is required."]}] + "code_references": [{"line_number": ["This field is required."]}], } assert not FeatureFlagCodeReferencesScan.objects.exists() + + +def test_CodeReferenceCreateAPIView__responds_400_when_file_path_too_long( + project: Project, + staff_client: APIClient, + with_project_permissions: WithProjectPermissionsCallable, +) -> None: + # Given + with_project_permissions([VIEW_PROJECT]) # type: ignore[call-arg] + + # When + response = staff_client.post( + f"/api/v1/projects/{project.pk}/code-references/", + data={ + "repository_url": "https://svn.flagsmith.com/", + "revision": "revision-hash", + "code_references": [ + { + "feature_name": "feature-1", + "file_path": "would/you/even/" * 1000 + "file.py", + "line_number": 10, + }, + ], + }, + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.data == { + "code_references": [ + {"file_path": ["Ensure this field has no more than 4096 characters."]} + ], + } + assert not FeatureFlagCodeReferencesScan.objects.exists() + + +def test_FeatureCodeReferencesDetailAPIView__responds_200_with_code_references_for_given_feature( + feature: Feature, + project: Project, + staff_client: APIClient, + with_project_permissions: WithProjectPermissionsCallable, +) -> None: + # Given + with_project_permissions([VIEW_PROJECT]) # type: ignore[call-arg] + with freezegun.freeze_time("2099-01-01T10:00:00-0300"): + FeatureFlagCodeReferencesScan.objects.create( + project=project, + repository_url="https://github.flagsmith.com/backend/", + revision="backend-1", + code_references=[ + { + "feature_name": feature.name, + "file_path": "backend/file1.py", + "line_number": 20, + }, + ], + ) + FeatureFlagCodeReferencesScan.objects.create( + project=project, + repository_url="https://github.flagsmith.com/frontend/", + revision="frontend-1", + code_references=[ + { + "feature_name": feature.name, + "file_path": "frontend/file1.js", + "line_number": 10, + }, + ], + ) + with freezegun.freeze_time("2099-01-02T11:00:00-0300"): + FeatureFlagCodeReferencesScan.objects.create( + project=project, + repository_url="https://github.flagsmith.com/frontend/", + revision="frontend-2", + code_references=[ + { + "feature_name": feature.name, + "file_path": "frontend/file1.js", + "line_number": 12, + }, + { + "feature_name": feature.name, + "file_path": "frontend/file2.js", + "line_number": 5, + }, + ], + ) + + # When + response = staff_client.get( + f"/api/v1/projects/{project.pk}/features/{feature.pk}/code-references/", + ) + + # Then + assert response.status_code == 200 + assert response.json() == [ + { + "repository_url": "https://github.flagsmith.com/backend/", + "vcs_provider": "github", + "revision": "backend-1", + "last_successful_repository_scanned_at": "2099-01-01T13:00:00+00:00", + "last_feature_found_at": "2099-01-01T13:00:00+00:00", + "code_references": [ + { + "feature_name": feature.name, + "file_path": "backend/file1.py", + "line_number": 20, + "permalink": ( + "https://github.flagsmith.com/backend/blob/backend-1/backend/file1.py#L20" + ), + }, + ], + }, + { + "repository_url": "https://github.flagsmith.com/frontend/", + "vcs_provider": "github", + "revision": "frontend-2", + "last_successful_repository_scanned_at": "2099-01-02T14:00:00+00:00", + "last_feature_found_at": "2099-01-02T14:00:00+00:00", + "code_references": [ + { + "feature_name": feature.name, + "file_path": "frontend/file1.js", + "line_number": 12, + "permalink": ( + "https://github.flagsmith.com/frontend/blob/frontend-2/frontend/file1.js#L12" + ), + }, + { + "feature_name": feature.name, + "file_path": "frontend/file2.js", + "line_number": 5, + "permalink": ( + "https://github.flagsmith.com/frontend/blob/frontend-2/frontend/file2.js#L5" + ), + }, + ], + }, + ] + + +def test_FeatureCodeReferencesDetailAPIView__responds_200_with_feature_flag_removed( + feature: Feature, + project: Project, + staff_client: APIClient, + with_project_permissions: WithProjectPermissionsCallable, +) -> None: + # Given + with_project_permissions([VIEW_PROJECT]) # type: ignore[call-arg] + with freezegun.freeze_time("2099-01-01T10:00:00-0300"): + FeatureFlagCodeReferencesScan.objects.create( + project=project, + repository_url="https://github.flagsmith.com/", + revision="revision-hash-1", + code_references=[ + { + "feature_name": feature.name, + "file_path": "path/to/file1.py", + "line_number": 10, + }, + ], + ) + with freezegun.freeze_time("2099-01-02T11:00:00-0300"): + FeatureFlagCodeReferencesScan.objects.create( + project=project, + repository_url="https://github.flagsmith.com/", + revision="revision-hash-2", + code_references=[], # Feature flag removed + ) + + # When + response = staff_client.get( + f"/api/v1/projects/{project.pk}/features/{feature.pk}/code-references/", + ) + + # Then + assert response.status_code == 200 + assert response.json() == [ + { + "repository_url": "https://github.flagsmith.com/", + "vcs_provider": "github", + "revision": "revision-hash-2", + "last_successful_repository_scanned_at": "2099-01-02T14:00:00+00:00", + "last_feature_found_at": "2099-01-01T13:00:00+00:00", + "code_references": [], + }, + ] + + +def test_FeatureCodeReferencesDetailAPIView__responds_200_even_without_code_references( + feature: Feature, + project: Project, + staff_client: APIClient, + with_project_permissions: WithProjectPermissionsCallable, +) -> None: + # Given + with_project_permissions([VIEW_PROJECT]) # type: ignore[call-arg] + + # When + response = staff_client.get( + f"/api/v1/projects/{project.pk}/features/{feature.pk}/code-references/", + ) + + # Then + assert response.status_code == 200 + assert response.json() == [] + + +def test_FeatureCodeReferencesDetailAPIView__responds_401_when_not_authenticated( + feature: Feature, + project: Project, + client: APIClient, +) -> None: + # When + response = client.get( + f"/api/v1/projects/{project.pk}/features/{feature.pk}/code-references/", + ) + + # Then + assert response.status_code == 401 + + +def test_FeatureCodeReferencesDetailAPIView__responds_404_when_feature_not_found( + project: Project, + staff_client: APIClient, + with_project_permissions: WithProjectPermissionsCallable, +) -> None: + # Given + with_project_permissions([VIEW_PROJECT]) # type: ignore[call-arg] + + # When + response = staff_client.get( + f"/api/v1/projects/{project.pk}/features/9999/code-references/", + ) + + # Then + assert response.status_code == 404 + assert response.data["detail"] == "No Feature matches the given query."