diff --git a/.github/workflows/poc-github-code-references.yml b/.github/workflows/poc-github-code-references.yml index aae1c3116f3d..5c4d5dc102bc 100644 --- a/.github/workflows/poc-github-code-references.yml +++ b/.github/workflows/poc-github-code-references.yml @@ -9,9 +9,9 @@ on: 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 - PYTHON_REQUESTS_VERSION: '2.32.4' + FLAGSMITH_ADMIN_API_URL: https://api.flagsmith.com + FLAGSMITH_ADMIN_API_KEY: ${{ secrets.FLAGSMITH_CODE_REFERENCES_API_KEY }} + FLAGSMITH_PROJECT_ID: 12 PYTHON_VERSION: '3.13' jobs: @@ -32,13 +32,13 @@ jobs: run: | uv run - < 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_ADMIN_API_KEY }}"}, + ) + response.raise_for_status() + return [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 = [ @@ -99,29 +107,47 @@ 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 - shell: python - run: | - import json - from collections import defaultdict - code_references = json.loads('''${{ steps.collect.outputs.code_references }}''') if not code_references: print("No code references found.") exit(0) references_by_feature = defaultdict(list) - sorted_code_references = sorted(code_references, key=lambda x: (x['feature_name'], x['file_path'], x['line_number'])) + sorted_code_references = sorted(code_references, key=lambda x: (x["feature_name"], x["file_path"], x["line_number"])) for reference in sorted_code_references: - references_by_feature[reference['feature_name']].append((reference['file_path'], reference['line_number'])) + references_by_feature[reference["feature_name"]].append((reference["file_path"], reference["line_number"])) print("Code References:") for feature_name, references in references_by_feature.items(): print(f"\nFeature: {feature_name}") for file_path, line_number in references: print(f" - {file_path}:{line_number}") + EOF - # TODO - # - name: Upload code references + - name: Upload code references + run: | + uv run - < bool: + if not super().has_permission(request, view): + return False + + if not isinstance(request.user, FFAdminUser): # pragma: no cover + return False + + project = Project.objects.get(id=view.kwargs["project_pk"]) + 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..f4be377c52e1 --- /dev/null +++ b/api/projects/code_references/serializers.py @@ -0,0 +1,39 @@ +from typing import TypedDict + +from rest_framework import serializers + +from projects.code_references.models import FeatureFlagCodeReferencesScan + + +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 FeatureFlagCodeReferencesScanSerializer( + serializers.ModelSerializer[FeatureFlagCodeReferencesScan], +): + code_references = _CodeReferenceSerializer( + many=True, required=True, allow_empty=False + ) + + class Meta: + model = FeatureFlagCodeReferencesScan + 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..85a56fb4a8cd --- /dev/null +++ b/api/projects/code_references/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from projects.code_references import views + +app_name = "code_references" + +urlpatterns = [ + path( + "projects//code-references/", + views.FeatureFlagCodeReferencesScanCreateAPIView.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..2bbdf75f7fde --- /dev/null +++ b/api/projects/code_references/views.py @@ -0,0 +1,21 @@ +from rest_framework import generics + +from projects.code_references.models import FeatureFlagCodeReferencesScan +from projects.code_references.permissions import SubmitFeatureFlagCodeReferences +from projects.code_references.serializers import FeatureFlagCodeReferencesScanSerializer + + +class FeatureFlagCodeReferencesScanCreateAPIView( + generics.CreateAPIView[FeatureFlagCodeReferencesScan] +): + """ + API view to create code references for a project + """ + + serializer_class = FeatureFlagCodeReferencesScanSerializer + permission_classes = [SubmitFeatureFlagCodeReferences] + + def perform_create( # type: ignore[override] + self, serializer: FeatureFlagCodeReferencesScanSerializer + ) -> None: + 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..a9b1c617ea14 --- /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 FeatureFlagCodeReferencesScan +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 FeatureFlagCodeReferencesScan.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 FeatureFlagCodeReferencesScan.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 FeatureFlagCodeReferencesScan.objects.exists()