Skip to content
Merged
13 changes: 12 additions & 1 deletion api/features/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions api/features/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"):
Expand Down
5 changes: 5 additions & 0 deletions api/projects/code_references/constants.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
11 changes: 5 additions & 6 deletions api/projects/code_references/models.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)

Expand Down
10 changes: 9 additions & 1 deletion api/projects/code_references/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
50 changes: 40 additions & 10 deletions api/projects/code_references/serializers.py
Original file line number Diff line number Diff line change
@@ -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
)

Expand All @@ -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)
158 changes: 158 additions & 0 deletions api/projects/code_references/services.py
Original file line number Diff line number Diff line change
@@ -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)"),
Comment thread
emyller marked this conversation as resolved.
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"),
Comment thread
emyller marked this conversation as resolved.
)
.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."
)
41 changes: 41 additions & 0 deletions api/projects/code_references/types.py
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
emyller marked this conversation as resolved.
repository_url: str
count: int
last_successful_repository_scanned_at: datetime
last_feature_found_at: datetime | None
5 changes: 5 additions & 0 deletions api/projects/code_references/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,9 @@
views.FeatureFlagCodeReferencesScanCreateAPIView.as_view(),
name="code_reference_create",
),
path(
"projects/<int:project_pk>/features/<int:feature_pk>/code-references/",
views.FeatureFlagCodeReferenceDetailAPIView.as_view(),
name="feature_code_reference_detail",
),
]
Loading
Loading