Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 48 additions & 22 deletions .github/workflows/poc-github-code-references.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -32,13 +32,13 @@ jobs:
run: |
uv run - <<EOF
# /// script
# requires-python = "==${{ env.PYTHON_VERSION }}"
# dependencies = ["requests==${{ env.PYTHON_REQUESTS_VERSION }}"]
# requires-python = ">=${{ env.PYTHON_VERSION }}"
# dependencies = ["requests"]
# ///
import json
import os
import re
from collections import deque
from collections import defaultdict, deque
from pathlib import Path
from typing import Generator

Expand Down Expand Up @@ -80,14 +80,22 @@ jobs:
for feature_name in feature_names:
if feature_name not in line: # Match feature name
continue
if re.search(fr"""(?i:(?:feature|flag)\w*\(\s*(["']){feature_name})\1""", "".join(context)): # Function calls
re_function_calls = rf"""(?i:(?:feature|flag)\w*\(\s*(["']){re.escape(feature_name)})\1"""
if re.search(re_function_calls, "".join(context)):
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",
Comment thread
khvn26 marked this conversation as resolved.
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 = [
Expand All @@ -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 - <<EOF
# /// script
# requires-python = ">=${{ env.PYTHON_VERSION }}"
# dependencies = ["requests"]
# ///
import json
import requests

code_references = json.loads("""${{ steps.collect.outputs.code_references }}""")
if not code_references:
print("No code references to upload.")
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_ADMIN_API_KEY }}"},
json={
"repository_url": "${{ github.server_url }}/${{ github.repository }}",
"revision": "${{ github.sha }}",
"code_references": code_references,
},
)
response.raise_for_status()
print(f"Uploaded {len(code_references)} code references.")
EOF
1 change: 1 addition & 0 deletions api/api/urls/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"drf_yasg",
"audit",
"permissions",
"projects.code_references",
"projects.tags",
"api_keys",
"webhooks",
Expand Down
Empty file.
6 changes: 6 additions & 0 deletions api/projects/code_references/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from core.apps import BaseAppConfig


class CodeReferencesConfig(BaseAppConfig):
name = "projects.code_references"
default = True
51 changes: 51 additions & 0 deletions api/projects/code_references/migrations/0001_code_references.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Generated by Django 4.2.22 on 2025-08-14 15:12

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

initial = True

dependencies = [
("projects", "0027_add_create_project_level_change_requests_permission"),
]

operations = [
migrations.CreateModel(
name="FeatureFlagCodeReferencesScan",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("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)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="code_references",
to="projects.project",
),
),
],
options={
"ordering": ["-created_at"],
},
),
]
Empty file.
32 changes: 32 additions & 0 deletions api/projects/code_references/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from django.db import models


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,
related_name="code_references",
)

# 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)
Comment thread
khvn26 marked this conversation as resolved.

created_at = models.DateTimeField(auto_now_add=True, db_index=True)

class Meta:
ordering = ["-created_at"]
19 changes: 19 additions & 0 deletions api/projects/code_references/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
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 SubmitFeatureFlagCodeReferences(IsAuthenticated):
def has_permission(self, request: Request, view: APIView) -> 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)
39 changes: 39 additions & 0 deletions api/projects/code_references/serializers.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
khvn26 marked this conversation as resolved.


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",
]
13 changes: 13 additions & 0 deletions api/projects/code_references/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.urls import path

from projects.code_references import views

app_name = "code_references"

urlpatterns = [
path(
"projects/<int:project_pk>/code-references/",
views.FeatureFlagCodeReferencesScanCreateAPIView.as_view(),
name="code_reference_create",
),
]
21 changes: 21 additions & 0 deletions api/projects/code_references/views.py
Original file line number Diff line number Diff line change
@@ -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"])
Loading
Loading