Skip to content

Commit b376502

Browse files
authored
ci(github): Find feature flag references in the code (#5854)
1 parent e59b3c6 commit b376502

1 file changed

Lines changed: 127 additions & 0 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
name: 'PoC: GitHub Code References'
2+
permissions:
3+
contents: read
4+
5+
on:
6+
schedule:
7+
- cron: '0 0 * * *' # Runs daily at midnight UTC
8+
workflow_dispatch:
9+
10+
env:
11+
EXCLUDE_PATTERNS: node_modules,venv,.git,cache,build,htmlcov,docs,.json,tests
12+
FLAGSMITH_EDGE_API_URL: https://edge.api.flagsmith.com
13+
FLAGSMITH_ENVIRONMENT_KEY: ENktaJnfLVbLifybz34JmX
14+
PYTHON_REQUESTS_VERSION: '2.32.4'
15+
PYTHON_VERSION: '3.13'
16+
17+
jobs:
18+
collect-code-references:
19+
runs-on: depot-ubuntu-latest
20+
steps:
21+
- name: Checkout code
22+
uses: actions/checkout@v4
23+
24+
- name: Set up Python ${{ env.PYTHON_VERSION }}
25+
uses: astral-sh/setup-uv@v6
26+
with:
27+
python-version: ${{ env.PYTHON_VERSION }}
28+
enable-cache: true
29+
30+
- name: Collect code references
31+
id: collect
32+
run: |
33+
uv run - <<EOF
34+
# /// script
35+
# requires-python = "==${{ env.PYTHON_VERSION }}"
36+
# dependencies = ["requests==${{ env.PYTHON_REQUESTS_VERSION }}"]
37+
# ///
38+
import json
39+
import os
40+
import re
41+
from collections import deque
42+
from pathlib import Path
43+
from typing import Generator
44+
45+
import requests
46+
47+
EXCLUDE_PATTERNS = os.environ["EXCLUDE_PATTERNS"].replace(" ", "").split(",")
48+
49+
def should_skip_file(file_path: Path) -> bool:
50+
"""Whether to skip a file based on its size or content"""
51+
file_size = file_path.stat().st_size
52+
if file_size == 0: # Empty files are irrelevant
53+
return True
54+
if file_size > 1024 * 1024: # Large files are likely binary
55+
return True
56+
with file_path.open("rb") as file:
57+
chunk = file.read(4096) # A text file rarely contains null bytes
58+
if b'\0' in chunk:
59+
return True
60+
try:
61+
chunk.decode('utf-8')
62+
except UnicodeDecodeError: # Decoding likely fails for binary files
63+
return True
64+
return False
65+
66+
def find_references(feature_names: list[str]) -> Generator[tuple[str, str, int], None, None]:
67+
"""Search for references to a feature name in the codebase."""
68+
all_files = Path('.').glob("**/*")
69+
for path in all_files:
70+
if any(pattern in str(path).lower() for pattern in EXCLUDE_PATTERNS):
71+
continue
72+
if not path.is_file():
73+
continue
74+
if should_skip_file(path):
75+
continue
76+
context: deque[str] = deque(maxlen=2)
77+
with path.open("r", encoding="utf-8", errors="ignore") as file:
78+
for line_number, line in enumerate(file, start=1):
79+
context.append(line)
80+
for feature_name in feature_names:
81+
if feature_name not in line: # Match feature name
82+
continue
83+
if re.search(fr"""(?i:(?:feature|flag)\w*\(\s*(["']){feature_name})\1""", "".join(context)): # Function calls
84+
yield feature_name, str(path), line_number
85+
# TODO: Add more sophisticated matching, e.g. feature names defined as constants
86+
87+
# Fetch visible features
88+
all_flags = requests.get(f"${{ env.FLAGSMITH_EDGE_API_URL }}/api/v1/flags", headers={"X-Environment-Key": "${{ env.FLAGSMITH_ENVIRONMENT_KEY }}"}).json()
89+
feature_names = sorted([flag["feature"]["name"] for flag in all_flags])
90+
print("Feature names:", feature_names)
91+
92+
# Find code references
93+
code_references = [
94+
{"feature_name": feature_name, "file_path": file_path, "line_number": line_number}
95+
for feature_name, file_path, line_number in find_references(feature_names)
96+
]
97+
98+
# Output to GHA
99+
json_references = json.dumps(code_references)
100+
with open(os.environ["GITHUB_OUTPUT"], "a") as gh_output:
101+
print(f"code_references={json_references}", file=gh_output)
102+
EOF
103+
104+
- name: Display code references
105+
shell: python
106+
run: |
107+
import json
108+
from collections import defaultdict
109+
110+
code_references = json.loads('''${{ steps.collect.outputs.code_references }}''')
111+
if not code_references:
112+
print("No code references found.")
113+
exit(0)
114+
115+
references_by_feature = defaultdict(list)
116+
sorted_code_references = sorted(code_references, key=lambda x: (x['feature_name'], x['file_path'], x['line_number']))
117+
for reference in sorted_code_references:
118+
references_by_feature[reference['feature_name']].append((reference['file_path'], reference['line_number']))
119+
120+
print("Code References:")
121+
for feature_name, references in references_by_feature.items():
122+
print(f"\nFeature: {feature_name}")
123+
for file_path, line_number in references:
124+
print(f" - {file_path}:{line_number}")
125+
126+
# TODO
127+
# - name: Upload code references

0 commit comments

Comments
 (0)