From 5e61fb348fc5f21d73ae43508f0379d7f1aa6c46 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 12 Aug 2025 18:48:12 -0300 Subject: [PATCH 01/14] Introduce VCS code references data model --- api/app/settings/common.py | 1 + api/projects/code_references/apps.py | 6 ++ .../migrations/0001_code_references.py | 62 +++++++++++++++++++ .../code_references/migrations/__init__.py | 0 api/projects/code_references/models.py | 35 +++++++++++ 5 files changed, 104 insertions(+) create mode 100644 api/projects/code_references/apps.py create mode 100644 api/projects/code_references/migrations/0001_code_references.py create mode 100644 api/projects/code_references/migrations/__init__.py create mode 100644 api/projects/code_references/models.py diff --git a/api/app/settings/common.py b/api/app/settings/common.py index b27541e18c02..b995bcfaaeac 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -122,6 +122,7 @@ "drf_yasg", "audit", "permissions", + "projects.code_references", "projects.tags", "api_keys", "webhooks", diff --git a/api/projects/code_references/apps.py b/api/projects/code_references/apps.py new file mode 100644 index 000000000000..1327ee402c91 --- /dev/null +++ b/api/projects/code_references/apps.py @@ -0,0 +1,6 @@ +from core.apps import BaseAppConfig + + +class CodeReferencesConfig(BaseAppConfig): + name = "projects.code_references" + default = True diff --git a/api/projects/code_references/migrations/0001_code_references.py b/api/projects/code_references/migrations/0001_code_references.py new file mode 100644 index 000000000000..2703569971d5 --- /dev/null +++ b/api/projects/code_references/migrations/0001_code_references.py @@ -0,0 +1,62 @@ +# Generated by Django 4.2.22 on 2025-08-12 21:38 + +import django.contrib.postgres.indexes +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("projects", "0027_add_create_project_level_change_requests_permission"), + ] + + operations = [ + migrations.CreateModel( + name="VCSFeatureFlagCodeReferences", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("repository_url", models.URLField()), + ("revision", models.CharField(max_length=100)), + ("code_references", models.JSONField(default=list)), + ( + "created_at", + models.DateTimeField( + db_index=True, default=django.utils.timezone.now + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="code_references", + to="projects.project", + ), + ), + ], + options={ + "ordering": ["-created_at"], + "indexes": [ + django.contrib.postgres.indexes.GinIndex( + models.Func( + models.F("code_references"), + models.Value("$[*].name"), + function="jsonb_path_query_array", + ), + name="code_references_feature_name", + ) + ], + }, + ), + ] diff --git a/api/projects/code_references/migrations/__init__.py b/api/projects/code_references/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/projects/code_references/models.py b/api/projects/code_references/models.py new file mode 100644 index 000000000000..d1b063ef476c --- /dev/null +++ b/api/projects/code_references/models.py @@ -0,0 +1,35 @@ +from django.contrib.postgres.indexes import GinIndex +from django.db import models +from django.db.models.expressions import Func +from django.utils import timezone + + +class VCSFeatureFlagCodeReferences(models.Model): + """ + A JSON package of feature flag code references within a repository + """ + + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="code_references", + ) + + repository_url = models.URLField() # Provider-agnostic + revision = models.CharField(max_length=100) + code_references = models.JSONField(default=list) + + created_at = models.DateTimeField(default=timezone.now, db_index=True) + + class Meta: + ordering = ["-created_at"] + indexes = [ + GinIndex( # Helps filtering code references by feature name + Func( + models.F("code_references"), + models.Value("$[*].name"), + function="jsonb_path_query_array", + ), + name="code_references_feature_name", + ), + ] From f833098b8269007fc20113eb030674682ffe0b4c Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 12 Aug 2025 19:34:37 -0300 Subject: [PATCH 02/14] Prefer auto_now_add --- .../code_references/migrations/0001_code_references.py | 10 ++-------- api/projects/code_references/models.py | 3 +-- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/api/projects/code_references/migrations/0001_code_references.py b/api/projects/code_references/migrations/0001_code_references.py index 2703569971d5..c19812be81e7 100644 --- a/api/projects/code_references/migrations/0001_code_references.py +++ b/api/projects/code_references/migrations/0001_code_references.py @@ -1,9 +1,8 @@ -# Generated by Django 4.2.22 on 2025-08-12 21:38 +# Generated by Django 4.2.22 on 2025-08-12 22:34 import django.contrib.postgres.indexes from django.db import migrations, models import django.db.models.deletion -import django.utils.timezone class Migration(migrations.Migration): @@ -30,12 +29,7 @@ class Migration(migrations.Migration): ("repository_url", models.URLField()), ("revision", models.CharField(max_length=100)), ("code_references", models.JSONField(default=list)), - ( - "created_at", - models.DateTimeField( - db_index=True, default=django.utils.timezone.now - ), - ), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), ( "project", models.ForeignKey( diff --git a/api/projects/code_references/models.py b/api/projects/code_references/models.py index d1b063ef476c..0ba25f5ce21f 100644 --- a/api/projects/code_references/models.py +++ b/api/projects/code_references/models.py @@ -1,7 +1,6 @@ from django.contrib.postgres.indexes import GinIndex from django.db import models from django.db.models.expressions import Func -from django.utils import timezone class VCSFeatureFlagCodeReferences(models.Model): @@ -19,7 +18,7 @@ class VCSFeatureFlagCodeReferences(models.Model): revision = models.CharField(max_length=100) code_references = models.JSONField(default=list) - created_at = models.DateTimeField(default=timezone.now, db_index=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) class Meta: ordering = ["-created_at"] From a108c13ce15a0fe791d5065599827f4c8e1b4192 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 12 Aug 2025 20:56:12 -0300 Subject: [PATCH 03/14] Submit code references --- api/api/urls/v1.py | 1 + api/projects/code_references/permissions.py | 22 +++ api/projects/code_references/serializers.py | 39 ++++++ api/projects/code_references/urls.py | 13 ++ api/projects/code_references/views.py | 22 +++ ...est_unit_projects_code_references_views.py | 128 ++++++++++++++++++ 6 files changed, 225 insertions(+) create mode 100644 api/projects/code_references/permissions.py create mode 100644 api/projects/code_references/serializers.py create mode 100644 api/projects/code_references/urls.py create mode 100644 api/projects/code_references/views.py create mode 100644 api/tests/unit/projects/code_references/test_unit_projects_code_references_views.py diff --git a/api/api/urls/v1.py b/api/api/urls/v1.py index b7e17d9a909e..1ea0e4274f8c 100644 --- a/api/api/urls/v1.py +++ b/api/api/urls/v1.py @@ -82,6 +82,7 @@ ), # Test webhook url re_path(r"^webhooks/", include("webhooks.urls", namespace="webhooks")), + path("", include("projects.code_references.urls", namespace="code_references")), ] if settings.SPLIT_TESTING_INSTALLED: diff --git a/api/projects/code_references/permissions.py b/api/projects/code_references/permissions.py new file mode 100644 index 000000000000..868c1de874d6 --- /dev/null +++ b/api/projects/code_references/permissions.py @@ -0,0 +1,22 @@ +from common.projects.permissions import VIEW_PROJECT +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.views import APIView + +from projects.models import Project +from users.models import FFAdminUser + + +class SubmitCodeReferences(IsAuthenticated): + def has_permission(self, request: Request, view: APIView) -> bool: + if not super().has_permission(request, view): + return False + + if not (project_id := view.kwargs.get("project_pk")): + return False + + if not isinstance(request.user, FFAdminUser): + return False + + project = Project.objects.get(id=project_id) + return request.user.has_project_permission(VIEW_PROJECT, project) diff --git a/api/projects/code_references/serializers.py b/api/projects/code_references/serializers.py new file mode 100644 index 000000000000..9144903c0f3e --- /dev/null +++ b/api/projects/code_references/serializers.py @@ -0,0 +1,39 @@ +from typing import TypedDict + +from rest_framework import serializers + +from .models import VCSFeatureFlagCodeReferences + + +class _CodeReference(TypedDict): + feature_name: str + file_path: str + line_number: int + + +class _CodeReferenceSerializer(serializers.Serializer[_CodeReference]): + feature_name = serializers.CharField(max_length=100) + file_path = serializers.CharField(max_length=200) + line_number = serializers.IntegerField(min_value=1) + + +class VCSFeatureFlagCodeReferencesSerializer( + serializers.ModelSerializer[VCSFeatureFlagCodeReferences], +): + code_references = _CodeReferenceSerializer( + many=True, required=True, allow_empty=False + ) + + class Meta: + model = VCSFeatureFlagCodeReferences + fields = [ + "created_at", + "repository_url", + "project", + "revision", + "code_references", + ] + read_only_fields = [ + "created_at", + "project", + ] diff --git a/api/projects/code_references/urls.py b/api/projects/code_references/urls.py new file mode 100644 index 000000000000..12e65e7e07e3 --- /dev/null +++ b/api/projects/code_references/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from . import views + +app_name = "code_references" + +urlpatterns = [ + path( + "projects//code_references/", + views.CodeReferenceCreateAPIView.as_view(), + name="code_reference_create", + ), +] diff --git a/api/projects/code_references/views.py b/api/projects/code_references/views.py new file mode 100644 index 000000000000..6fcf4bfbe72d --- /dev/null +++ b/api/projects/code_references/views.py @@ -0,0 +1,22 @@ +from rest_framework import generics + +from .models import VCSFeatureFlagCodeReferences +from .permissions import SubmitCodeReferences +from .serializers import VCSFeatureFlagCodeReferencesSerializer + + +class CodeReferenceCreateAPIView(generics.CreateAPIView[VCSFeatureFlagCodeReferences]): + """ + API view to create code references for a project + """ + + serializer_class = VCSFeatureFlagCodeReferencesSerializer + permission_classes = [SubmitCodeReferences] + + def perform_create( # type: ignore[override] + self, serializer: VCSFeatureFlagCodeReferencesSerializer + ) -> None: + """ + Save the code references with the project context + """ + serializer.save(project_id=self.kwargs["project_pk"]) 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 new file mode 100644 index 000000000000..80864012e258 --- /dev/null +++ b/api/tests/unit/projects/code_references/test_unit_projects_code_references_views.py @@ -0,0 +1,128 @@ +import freezegun +from common.projects.permissions import VIEW_PROJECT +from rest_framework.test import APIClient + +from projects.code_references.models import VCSFeatureFlagCodeReferences +from projects.models import Project +from tests.types import WithProjectPermissionsCallable + + +@freezegun.freeze_time("2025-04-14T09:30:00-0300") +def test_CodeReferenceCreateAPIView__responds_201_with_accepted_code_references( + 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": "path/to/file1.py", + "line_number": 10, + }, + { + "feature_name": "feature-1", + "file_path": "path/to/file2.py", + "line_number": 20, + }, + { + "feature_name": "feature-2", + "file_path": "path/to/file3.py", + "line_number": 30, + }, + ], + }, + format="json", + ) + + # Then + assert response.status_code == 201 + assert response.data["repository_url"] == "https://svn.flagsmith.com/" + assert response.data["revision"] == "revision-hash" + assert len(response.data["code_references"]) == 3 + assert response.data["project"] == project.pk + assert response.data["created_at"] == "2025-04-14T12:30:00Z" + assert VCSFeatureFlagCodeReferences.objects.get().code_references == [ + { + "feature_name": "feature-1", + "file_path": "path/to/file1.py", + "line_number": 10, + }, + { + "feature_name": "feature-1", + "file_path": "path/to/file2.py", + "line_number": 20, + }, + { + "feature_name": "feature-2", + "file_path": "path/to/file3.py", + "line_number": 30, + }, + ] + + +def test_CodeReferenceCreateAPIView__responds_401_when_not_authenticated( + project: Project, + client: APIClient, +) -> None: + # When + response = 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": "path/to/file1.py", + "line_number": 10, + }, + ], + }, + format="json", + ) + + # Then + assert response.status_code == 401 + assert not VCSFeatureFlagCodeReferences.objects.exists() + + +def test_CodeReferenceCreateAPIView__responds_400_when_invalid_data( + 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": "path/to/file1.py", + # Missing line_number + }, + ], + }, + format="json", + ) + + # Then + assert response.status_code == 400 + assert response.data == { + "code_references": [{"line_number": ["This field is required."]}] + } + assert not VCSFeatureFlagCodeReferences.objects.exists() From 394914cd7150be049204aa3070a53760565bd818 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Wed, 13 Aug 2025 13:53:00 -0300 Subject: [PATCH 04/14] Oops --- api/projects/code_references/migrations/0001_code_references.py | 2 +- api/projects/code_references/models.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/projects/code_references/migrations/0001_code_references.py b/api/projects/code_references/migrations/0001_code_references.py index c19812be81e7..42e97ece0ac7 100644 --- a/api/projects/code_references/migrations/0001_code_references.py +++ b/api/projects/code_references/migrations/0001_code_references.py @@ -45,7 +45,7 @@ class Migration(migrations.Migration): django.contrib.postgres.indexes.GinIndex( models.Func( models.F("code_references"), - models.Value("$[*].name"), + models.Value("$[*].feature_name"), function="jsonb_path_query_array", ), name="code_references_feature_name", diff --git a/api/projects/code_references/models.py b/api/projects/code_references/models.py index 0ba25f5ce21f..7e6d52de1f4d 100644 --- a/api/projects/code_references/models.py +++ b/api/projects/code_references/models.py @@ -26,7 +26,7 @@ class Meta: GinIndex( # Helps filtering code references by feature name Func( models.F("code_references"), - models.Value("$[*].name"), + models.Value("$[*].feature_name"), function="jsonb_path_query_array", ), name="code_references_feature_name", From 30821235eb56c3e55656e9d5df534b8005b76015 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Wed, 13 Aug 2025 14:09:52 -0300 Subject: [PATCH 05/14] Improve URL --- api/projects/code_references/urls.py | 2 +- .../test_unit_projects_code_references_views.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/projects/code_references/urls.py b/api/projects/code_references/urls.py index 12e65e7e07e3..79dce758fc52 100644 --- a/api/projects/code_references/urls.py +++ b/api/projects/code_references/urls.py @@ -6,7 +6,7 @@ urlpatterns = [ path( - "projects//code_references/", + "projects//code-references/", views.CodeReferenceCreateAPIView.as_view(), name="code_reference_create", ), 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 80864012e258..1af6a36ba9eb 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 @@ -18,7 +18,7 @@ def test_CodeReferenceCreateAPIView__responds_201_with_accepted_code_references( # When response = staff_client.post( - f"/api/v1/projects/{project.pk}/code_references/", + f"/api/v1/projects/{project.pk}/code-references/", data={ "repository_url": "https://svn.flagsmith.com/", "revision": "revision-hash", @@ -75,7 +75,7 @@ def test_CodeReferenceCreateAPIView__responds_401_when_not_authenticated( ) -> None: # When response = client.post( - f"/api/v1/projects/{project.pk}/code_references/", + f"/api/v1/projects/{project.pk}/code-references/", data={ "repository_url": "https://svn.flagsmith.com/", "revision": "revision-hash", @@ -105,7 +105,7 @@ def test_CodeReferenceCreateAPIView__responds_400_when_invalid_data( # When response = staff_client.post( - f"/api/v1/projects/{project.pk}/code_references/", + f"/api/v1/projects/{project.pk}/code-references/", data={ "repository_url": "https://svn.flagsmith.com/", "revision": "revision-hash", From 85e84950e931807d9dda03f0d76c2ffb1891a625 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Wed, 13 Aug 2025 14:36:46 -0300 Subject: [PATCH 06/14] Experiment uploading code references --- .../workflows/poc-github-code-references.yml | 57 ++++++++++++++++--- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/.github/workflows/poc-github-code-references.yml b/.github/workflows/poc-github-code-references.yml index aae1c3116f3d..546edb436148 100644 --- a/.github/workflows/poc-github-code-references.yml +++ b/.github/workflows/poc-github-code-references.yml @@ -3,14 +3,16 @@ permissions: contents: read on: + pull_request: schedule: - cron: '0 0 * * *' # Runs daily at midnight UTC workflow_dispatch: env: EXCLUDE_PATTERNS: node_modules,venv,.git,cache,build,htmlcov,docs,.json,tests - FLAGSMITH_EDGE_API_URL: https://edge.api.flagsmith.com - FLAGSMITH_ENVIRONMENT_KEY: ENktaJnfLVbLifybz34JmX + FLAGSMITH_ADMIN_API_URL: https://api.flagsmith.com + FLAGSMITH_SERVER_KEY: ${{ secrets.FLAGSMITH_CODE_REFERENCES_API_KEY }} + FLAGSMITH_PROJECT_ID: 12 PYTHON_REQUESTS_VERSION: '2.32.4' PYTHON_VERSION: '3.13' @@ -84,10 +86,17 @@ jobs: yield feature_name, str(path), line_number # TODO: Add more sophisticated matching, e.g. feature names defined as constants + def retrieve_feature_names() -> list[str]: + """Fetch feature names from the Flagsmith API.""" + response = requests.get( # TODO: Make better use of pagination + f"${{ env.FLAGSMITH_ADMIN_API_URL }}/api/v1/projects/${{ env.FLAGSMITH_PROJECT_ID }}/features/?page_size=1000", + headers={"Authorization": f"Api-Key ${{ env.FLAGSMITH_SERVER_KEY }}"}, + ) + response.raise_for_status() + return sorted(feature["name"] for feature in response.json()["results"]) + # Fetch visible features - all_flags = requests.get(f"${{ env.FLAGSMITH_EDGE_API_URL }}/api/v1/flags", headers={"X-Environment-Key": "${{ env.FLAGSMITH_ENVIRONMENT_KEY }}"}).json() - feature_names = sorted([flag["feature"]["name"] for flag in all_flags]) - print("Feature names:", feature_names) + feature_names = retrieve_feature_names() # Find code references code_references = [ @@ -102,8 +111,11 @@ jobs: EOF - name: Display code references - shell: python run: | + uv run - < Date: Wed, 13 Aug 2025 15:19:07 -0300 Subject: [PATCH 07/14] =?UTF-8?q?Wrap=20up=20=F0=9F=8E=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/poc-github-code-references.yml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/workflows/poc-github-code-references.yml b/.github/workflows/poc-github-code-references.yml index 546edb436148..1df5a0cf65c8 100644 --- a/.github/workflows/poc-github-code-references.yml +++ b/.github/workflows/poc-github-code-references.yml @@ -3,7 +3,6 @@ permissions: contents: read on: - pull_request: schedule: - cron: '0 0 * * *' # Runs daily at midnight UTC workflow_dispatch: @@ -11,7 +10,7 @@ on: env: EXCLUDE_PATTERNS: node_modules,venv,.git,cache,build,htmlcov,docs,.json,tests FLAGSMITH_ADMIN_API_URL: https://api.flagsmith.com - FLAGSMITH_SERVER_KEY: ${{ secrets.FLAGSMITH_CODE_REFERENCES_API_KEY }} + FLAGSMITH_ADMIN_API_KEY: ${{ secrets.FLAGSMITH_CODE_REFERENCES_API_KEY }} FLAGSMITH_PROJECT_ID: 12 PYTHON_REQUESTS_VERSION: '2.32.4' PYTHON_VERSION: '3.13' @@ -90,7 +89,7 @@ jobs: """Fetch feature names from the Flagsmith API.""" response = requests.get( # TODO: Make better use of pagination f"${{ env.FLAGSMITH_ADMIN_API_URL }}/api/v1/projects/${{ env.FLAGSMITH_PROJECT_ID }}/features/?page_size=1000", - headers={"Authorization": f"Api-Key ${{ env.FLAGSMITH_SERVER_KEY }}"}, + headers={"Authorization": f"Api-Key ${{ env.FLAGSMITH_ADMIN_API_KEY }}"}, ) response.raise_for_status() return sorted(feature["name"] for feature in response.json()["results"]) @@ -153,10 +152,8 @@ jobs: exit(0) response = requests.post( - # f"${{ env.FLAGSMITH_ADMIN_API_URL }}/api/v1/projects/${{ env.FLAGSMITH_PROJECT_ID }}/code-references/", - # headers={"Authorization": f"Api-Key ${{ env.FLAGSMITH_SERVER_KEY }}"}, - "https://mocktarget.apigee.net/echo", - headers={"Authorization": f"Api-Key beware-the-hacker"}, + f"${{ env.FLAGSMITH_ADMIN_API_URL }}/api/v1/projects/${{ env.FLAGSMITH_PROJECT_ID }}/code-references/", + headers={"Authorization": f"Api-Key ${{ env.FLAGSMITH_ADMIN_API_KEY }}"}, json={ "repository_url": "${{ github.server_url }}/${{ github.repository }}", "revision": "${{ github.sha }}", @@ -164,5 +161,5 @@ jobs: }, ) response.raise_for_status() - print("Code references uploaded successfully.") + print(f"Uploaded {len(code_references)} code references.") EOF From 6ddc4a8b66d07075b042404d25e729f14bf5483f Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Wed, 13 Aug 2025 15:27:08 -0300 Subject: [PATCH 08/14] _Improve_ coverage --- api/projects/code_references/permissions.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/api/projects/code_references/permissions.py b/api/projects/code_references/permissions.py index 868c1de874d6..f9e81ff6524c 100644 --- a/api/projects/code_references/permissions.py +++ b/api/projects/code_references/permissions.py @@ -12,11 +12,8 @@ def has_permission(self, request: Request, view: APIView) -> bool: if not super().has_permission(request, view): return False - if not (project_id := view.kwargs.get("project_pk")): + if not isinstance(request.user, FFAdminUser): # pragma: no cover return False - if not isinstance(request.user, FFAdminUser): - return False - - project = Project.objects.get(id=project_id) + project = Project.objects.get(id=view.kwargs["project_pk"]) return request.user.has_project_permission(VIEW_PROJECT, project) From 1a51c3efe56829c73fae1557a96c6a093850dba3 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Wed, 13 Aug 2025 19:27:41 -0300 Subject: [PATCH 09/14] =?UTF-8?q?=F0=9F=92=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflows/poc-github-code-references.yml | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/poc-github-code-references.yml b/.github/workflows/poc-github-code-references.yml index 1df5a0cf65c8..53112d11b856 100644 --- a/.github/workflows/poc-github-code-references.yml +++ b/.github/workflows/poc-github-code-references.yml @@ -33,7 +33,7 @@ jobs: run: | uv run - < Date: Wed, 13 Aug 2025 22:26:15 -0300 Subject: [PATCH 10/14] =?UTF-8?q?=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/projects/code_references/__init__.py | 0 .../migrations/0001_code_references.py | 4 ++-- api/projects/code_references/models.py | 2 +- api/projects/code_references/permissions.py | 2 +- api/projects/code_references/serializers.py | 8 ++++---- api/projects/code_references/urls.py | 4 ++-- api/projects/code_references/views.py | 19 +++++++++---------- ...est_unit_projects_code_references_views.py | 8 ++++---- 8 files changed, 23 insertions(+), 24 deletions(-) create mode 100644 api/projects/code_references/__init__.py diff --git a/api/projects/code_references/__init__.py b/api/projects/code_references/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/projects/code_references/migrations/0001_code_references.py b/api/projects/code_references/migrations/0001_code_references.py index 42e97ece0ac7..07ad2559a8c7 100644 --- a/api/projects/code_references/migrations/0001_code_references.py +++ b/api/projects/code_references/migrations/0001_code_references.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.22 on 2025-08-12 22:34 +# Generated by Django 4.2.22 on 2025-08-14 01:32 import django.contrib.postgres.indexes from django.db import migrations, models @@ -15,7 +15,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name="VCSFeatureFlagCodeReferences", + name="FeatureFlagCodeReferencesScan", fields=[ ( "id", diff --git a/api/projects/code_references/models.py b/api/projects/code_references/models.py index 7e6d52de1f4d..58c711248d06 100644 --- a/api/projects/code_references/models.py +++ b/api/projects/code_references/models.py @@ -3,7 +3,7 @@ from django.db.models.expressions import Func -class VCSFeatureFlagCodeReferences(models.Model): +class FeatureFlagCodeReferencesScan(models.Model): """ A JSON package of feature flag code references within a repository """ diff --git a/api/projects/code_references/permissions.py b/api/projects/code_references/permissions.py index f9e81ff6524c..c9c08b9b8ef0 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 SubmitCodeReferences(IsAuthenticated): +class SubmitFeatureFlagCodeReferences(IsAuthenticated): def has_permission(self, request: Request, view: APIView) -> bool: if not super().has_permission(request, view): return False diff --git a/api/projects/code_references/serializers.py b/api/projects/code_references/serializers.py index 9144903c0f3e..f4be377c52e1 100644 --- a/api/projects/code_references/serializers.py +++ b/api/projects/code_references/serializers.py @@ -2,7 +2,7 @@ from rest_framework import serializers -from .models import VCSFeatureFlagCodeReferences +from projects.code_references.models import FeatureFlagCodeReferencesScan class _CodeReference(TypedDict): @@ -17,15 +17,15 @@ class _CodeReferenceSerializer(serializers.Serializer[_CodeReference]): line_number = serializers.IntegerField(min_value=1) -class VCSFeatureFlagCodeReferencesSerializer( - serializers.ModelSerializer[VCSFeatureFlagCodeReferences], +class FeatureFlagCodeReferencesScanSerializer( + serializers.ModelSerializer[FeatureFlagCodeReferencesScan], ): code_references = _CodeReferenceSerializer( many=True, required=True, allow_empty=False ) class Meta: - model = VCSFeatureFlagCodeReferences + model = FeatureFlagCodeReferencesScan fields = [ "created_at", "repository_url", diff --git a/api/projects/code_references/urls.py b/api/projects/code_references/urls.py index 79dce758fc52..85a56fb4a8cd 100644 --- a/api/projects/code_references/urls.py +++ b/api/projects/code_references/urls.py @@ -1,13 +1,13 @@ from django.urls import path -from . import views +from projects.code_references import views app_name = "code_references" urlpatterns = [ path( "projects//code-references/", - views.CodeReferenceCreateAPIView.as_view(), + views.FeatureFlagCodeReferencesScanCreateAPIView.as_view(), name="code_reference_create", ), ] diff --git a/api/projects/code_references/views.py b/api/projects/code_references/views.py index 6fcf4bfbe72d..2bbdf75f7fde 100644 --- a/api/projects/code_references/views.py +++ b/api/projects/code_references/views.py @@ -1,22 +1,21 @@ from rest_framework import generics -from .models import VCSFeatureFlagCodeReferences -from .permissions import SubmitCodeReferences -from .serializers import VCSFeatureFlagCodeReferencesSerializer +from projects.code_references.models import FeatureFlagCodeReferencesScan +from projects.code_references.permissions import SubmitFeatureFlagCodeReferences +from projects.code_references.serializers import FeatureFlagCodeReferencesScanSerializer -class CodeReferenceCreateAPIView(generics.CreateAPIView[VCSFeatureFlagCodeReferences]): +class FeatureFlagCodeReferencesScanCreateAPIView( + generics.CreateAPIView[FeatureFlagCodeReferencesScan] +): """ API view to create code references for a project """ - serializer_class = VCSFeatureFlagCodeReferencesSerializer - permission_classes = [SubmitCodeReferences] + serializer_class = FeatureFlagCodeReferencesScanSerializer + permission_classes = [SubmitFeatureFlagCodeReferences] def perform_create( # type: ignore[override] - self, serializer: VCSFeatureFlagCodeReferencesSerializer + self, serializer: FeatureFlagCodeReferencesScanSerializer ) -> None: - """ - Save the code references with the project context - """ serializer.save(project_id=self.kwargs["project_pk"]) 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 1af6a36ba9eb..a9b1c617ea14 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,7 +2,7 @@ from common.projects.permissions import VIEW_PROJECT from rest_framework.test import APIClient -from projects.code_references.models import VCSFeatureFlagCodeReferences +from projects.code_references.models import FeatureFlagCodeReferencesScan from projects.models import Project from tests.types import WithProjectPermissionsCallable @@ -50,7 +50,7 @@ def test_CodeReferenceCreateAPIView__responds_201_with_accepted_code_references( assert len(response.data["code_references"]) == 3 assert response.data["project"] == project.pk assert response.data["created_at"] == "2025-04-14T12:30:00Z" - assert VCSFeatureFlagCodeReferences.objects.get().code_references == [ + assert FeatureFlagCodeReferencesScan.objects.get().code_references == [ { "feature_name": "feature-1", "file_path": "path/to/file1.py", @@ -92,7 +92,7 @@ def test_CodeReferenceCreateAPIView__responds_401_when_not_authenticated( # Then assert response.status_code == 401 - assert not VCSFeatureFlagCodeReferences.objects.exists() + assert not FeatureFlagCodeReferencesScan.objects.exists() def test_CodeReferenceCreateAPIView__responds_400_when_invalid_data( @@ -125,4 +125,4 @@ def test_CodeReferenceCreateAPIView__responds_400_when_invalid_data( assert response.data == { "code_references": [{"line_number": ["This field is required."]}] } - assert not VCSFeatureFlagCodeReferences.objects.exists() + assert not FeatureFlagCodeReferencesScan.objects.exists() From c4c7245178deb971f60c2a6729e3eaec9a7206f2 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Thu, 14 Aug 2025 12:00:19 -0300 Subject: [PATCH 11/14] Let's just requests --- .github/workflows/poc-github-code-references.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/poc-github-code-references.yml b/.github/workflows/poc-github-code-references.yml index 53112d11b856..812d422f094d 100644 --- a/.github/workflows/poc-github-code-references.yml +++ b/.github/workflows/poc-github-code-references.yml @@ -12,7 +12,6 @@ env: FLAGSMITH_ADMIN_API_URL: https://api.flagsmith.com FLAGSMITH_ADMIN_API_KEY: ${{ secrets.FLAGSMITH_CODE_REFERENCES_API_KEY }} FLAGSMITH_PROJECT_ID: 12 - PYTHON_REQUESTS_VERSION: '2.32.4' PYTHON_VERSION: '3.13' jobs: @@ -34,7 +33,7 @@ jobs: uv run - < Date: Thu, 14 Aug 2025 12:13:38 -0300 Subject: [PATCH 12/14] For each thing its own turn --- .../migrations/0001_code_references.py | 13 +------------ api/projects/code_references/models.py | 12 ------------ 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/api/projects/code_references/migrations/0001_code_references.py b/api/projects/code_references/migrations/0001_code_references.py index 07ad2559a8c7..192a5dda4ade 100644 --- a/api/projects/code_references/migrations/0001_code_references.py +++ b/api/projects/code_references/migrations/0001_code_references.py @@ -1,6 +1,5 @@ -# Generated by Django 4.2.22 on 2025-08-14 01:32 +# Generated by Django 4.2.22 on 2025-08-14 15:12 -import django.contrib.postgres.indexes from django.db import migrations, models import django.db.models.deletion @@ -41,16 +40,6 @@ class Migration(migrations.Migration): ], options={ "ordering": ["-created_at"], - "indexes": [ - django.contrib.postgres.indexes.GinIndex( - models.Func( - models.F("code_references"), - models.Value("$[*].feature_name"), - function="jsonb_path_query_array", - ), - name="code_references_feature_name", - ) - ], }, ), ] diff --git a/api/projects/code_references/models.py b/api/projects/code_references/models.py index 58c711248d06..602362529f27 100644 --- a/api/projects/code_references/models.py +++ b/api/projects/code_references/models.py @@ -1,6 +1,4 @@ -from django.contrib.postgres.indexes import GinIndex from django.db import models -from django.db.models.expressions import Func class FeatureFlagCodeReferencesScan(models.Model): @@ -22,13 +20,3 @@ class FeatureFlagCodeReferencesScan(models.Model): class Meta: ordering = ["-created_at"] - indexes = [ - GinIndex( # Helps filtering code references by feature name - Func( - models.F("code_references"), - models.Value("$[*].feature_name"), - function="jsonb_path_query_array", - ), - name="code_references_feature_name", - ), - ] From c5d25931908698e7158e175360f461ab1d384c6f Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Thu, 14 Aug 2025 12:34:37 -0300 Subject: [PATCH 13/14] Simplify CI --- .github/workflows/poc-github-code-references.yml | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/.github/workflows/poc-github-code-references.yml b/.github/workflows/poc-github-code-references.yml index 812d422f094d..5c4d5dc102bc 100644 --- a/.github/workflows/poc-github-code-references.yml +++ b/.github/workflows/poc-github-code-references.yml @@ -38,7 +38,7 @@ jobs: import json import os import re - from collections import deque + from collections import defaultdict, deque from pathlib import Path from typing import Generator @@ -107,18 +107,7 @@ jobs: json_references = json.dumps(code_references) with open(os.environ["GITHUB_OUTPUT"], "a") as gh_output: print(f"code_references={json_references}", file=gh_output) - EOF - - name: Display code references - run: | - uv run - < Date: Thu, 14 Aug 2025 13:13:08 -0300 Subject: [PATCH 14/14] Improve designating relation to the VCS --- .../migrations/0001_code_references.py | 6 ++++++ api/projects/code_references/models.py | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/api/projects/code_references/migrations/0001_code_references.py b/api/projects/code_references/migrations/0001_code_references.py index 192a5dda4ade..478eff2f1aa6 100644 --- a/api/projects/code_references/migrations/0001_code_references.py +++ b/api/projects/code_references/migrations/0001_code_references.py @@ -26,6 +26,12 @@ class Migration(migrations.Migration): ), ), ("repository_url", models.URLField()), + ( + "vcs_provider", + models.CharField( + choices=[("github", "GitHub")], default="github", max_length=50 + ), + ), ("revision", models.CharField(max_length=100)), ("code_references", models.JSONField(default=list)), ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), diff --git a/api/projects/code_references/models.py b/api/projects/code_references/models.py index 602362529f27..b8abb7c58b50 100644 --- a/api/projects/code_references/models.py +++ b/api/projects/code_references/models.py @@ -3,16 +3,26 @@ class FeatureFlagCodeReferencesScan(models.Model): """ - A JSON package of feature flag code references within a repository + 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, related_name="code_references", ) - repository_url = models.URLField() # Provider-agnostic + # Provider-agnostic URL to the web UI of the repository, e.g. https://github.flagsmith.com/backend/ + repository_url = models.URLField() + + vcs_provider = models.CharField( + max_length=50, + choices=Providers.choices, + default=Providers.GITHUB, # TODO: Remove when adding other providers + ) revision = models.CharField(max_length=100) code_references = models.JSONField(default=list)