Skip to content

Commit 34624a3

Browse files
authored
feat(Hackathon): Submit a GitHub issue to clean up a stale feature (#6691)
1 parent 264d1f9 commit 34624a3

8 files changed

Lines changed: 487 additions & 1 deletion

File tree

api/app/settings/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,6 +1030,7 @@
10301030
GITHUB_PEM = env.str("GITHUB_PEM", default="")
10311031
GITHUB_APP_ID: int = env.int("GITHUB_APP_ID", default=0)
10321032
GITHUB_WEBHOOK_SECRET = env.str("GITHUB_WEBHOOK_SECRET", default="")
1033+
FEATURE_LIFECYCLE_GITHUB_PAT = env.str("FEATURE_LIFECYCLE_GITHUB_PAT", default="")
10331034

10341035
# Additional functionality for using SAML in Flagsmith SaaS
10351036
SAML_INSTALLED = importlib.util.find_spec("saml") is not None

api/integrations/github/client.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,28 @@ def build_paginated_response(
9898
return data
9999

100100

101+
def create_github_issue(
102+
github_pat: str,
103+
api_url: str,
104+
owner: str,
105+
repo: str,
106+
title: str,
107+
body: str,
108+
) -> dict[str, Any]:
109+
url = f"{api_url}repos/{owner}/{repo}/issues"
110+
headers = {
111+
"X-GitHub-Api-Version": GITHUB_API_VERSION,
112+
"Accept": "application/vnd.github.v3+json",
113+
"Authorization": f"token {github_pat}",
114+
}
115+
payload = {"title": title, "body": body}
116+
response = requests.post(
117+
url, json=payload, headers=headers, timeout=GITHUB_API_CALLS_TIMEOUT
118+
)
119+
response.raise_for_status()
120+
return response.json() # type: ignore[no-any-return]
121+
122+
101123
def post_comment_to_github(
102124
installation_id: str, owner: str, repo: str, issue: str, body: str
103125
) -> dict[str, Any]:

api/integrations/github/constants.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@
2121
FEATURE_ENVIRONMENT_URL = "%s/project/%s/environment/%s/features?feature=%s&tab=%s"
2222
GITHUB_API_CALLS_TIMEOUT = 10
2323

24+
CLEANUP_ISSUE_TITLE = "Remove stale feature flag: %s"
25+
CLEANUP_ISSUE_BODY = """\
26+
We need to clean up feature flag usage in the code.
27+
Our goal is to delete references of the "%s" feature.
28+
We need to delete the feature flag check so that the code path \
29+
is no longer guarded by the feature flag.
30+
These are the occurrences of this feature flag in this repository:
31+
%s"""
32+
2433
GITHUB_TAG_COLOR = "#838992"
2534
GITHUB_FLAGSMITH_LABEL = "Flagsmith Flag"
2635
GITHUB_FLAGSMITH_LABEL_DESCRIPTION = (

api/integrations/github/serializers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ class Meta:
3535
)
3636

3737

38+
class CreateCleanupIssueSerializer(serializers.Serializer): # type: ignore[type-arg]
39+
feature_id = serializers.IntegerField()
40+
41+
3842
class PaginatedQueryParamsSerializer(DataclassSerializer): # type: ignore[type-arg]
3943
class Meta:
4044
dataclass = PaginatedQueryParams

api/integrations/github/views.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import re
44
from functools import wraps
55
from typing import Any, Callable
6+
from urllib.parse import urlparse
67

78
import requests
89
from django.conf import settings
@@ -13,14 +14,26 @@
1314
from rest_framework.permissions import AllowAny, IsAuthenticated
1415
from rest_framework.response import Response
1516

17+
from features.feature_external_resources.models import (
18+
FeatureExternalResource,
19+
)
20+
from features.feature_external_resources.models import (
21+
ResourceType as ExternalResourceType,
22+
)
23+
from features.models import Feature
1624
from integrations.github.client import (
1725
ResourceType,
1826
create_flagsmith_flag_label,
27+
create_github_issue,
1928
delete_github_installation,
2029
fetch_github_repo_contributors,
2130
fetch_github_repositories,
2231
fetch_search_github_resource,
2332
)
33+
from integrations.github.constants import (
34+
CLEANUP_ISSUE_BODY,
35+
CLEANUP_ISSUE_TITLE,
36+
)
2437
from integrations.github.exceptions import DuplicateGitHubIntegration
2538
from integrations.github.github import (
2639
handle_github_webhook_event,
@@ -30,13 +43,15 @@
3043
from integrations.github.models import GithubConfiguration, GitHubRepository
3144
from integrations.github.permissions import HasPermissionToGithubConfiguration
3245
from integrations.github.serializers import (
46+
CreateCleanupIssueSerializer,
3347
GithubConfigurationSerializer,
3448
GithubRepositorySerializer,
3549
IssueQueryParamsSerializer,
3650
PaginatedQueryParamsSerializer,
3751
RepoQueryParamsSerializer,
3852
)
3953
from organisations.permissions.permissions import GithubIsAdminOrganisation
54+
from projects.code_references.services import get_code_references_for_feature_flag
4055

4156
logger = logging.getLogger(__name__)
4257

@@ -270,6 +285,107 @@ def fetch_repo_contributors(request, organisation_pk) -> Response: # type: igno
270285
)
271286

272287

288+
def _get_github_api_url(repository_url: str) -> str:
289+
"""Derive the GitHub API base URL from a repository URL.
290+
291+
For github.com: https://api.github.com/
292+
For enterprise: https://{host}/api/v3/
293+
"""
294+
parsed = urlparse(repository_url)
295+
if parsed.hostname == "github.com":
296+
return "https://api.github.com/"
297+
return f"{parsed.scheme}://{parsed.hostname}/api/v3/"
298+
299+
300+
@api_view(["POST"])
301+
@permission_classes([IsAuthenticated, HasPermissionToGithubConfiguration])
302+
@github_api_call_error_handler(error="Failed to create GitHub cleanup issue.")
303+
def create_cleanup_issue(request, organisation_pk: int) -> Response: # type: ignore[no-untyped-def]
304+
serializer = CreateCleanupIssueSerializer(data=request.data)
305+
if not serializer.is_valid():
306+
return Response(
307+
{"error": serializer.errors}, status=status.HTTP_400_BAD_REQUEST
308+
)
309+
310+
github_pat: str = settings.FEATURE_LIFECYCLE_GITHUB_PAT
311+
if not github_pat:
312+
return Response(
313+
data={"detail": "GitHub PAT is not configured."},
314+
status=status.HTTP_400_BAD_REQUEST,
315+
)
316+
317+
feature_id: int = serializer.validated_data["feature_id"]
318+
319+
# Validate the feature exists and belongs to this org.
320+
try:
321+
feature = Feature.objects.get(
322+
id=feature_id,
323+
project__organisation_id=organisation_pk,
324+
)
325+
except Feature.DoesNotExist:
326+
return Response(
327+
data={"detail": "Feature not found in this organisation."},
328+
status=status.HTTP_404_NOT_FOUND,
329+
)
330+
331+
# Get code references for the feature across all repositories.
332+
summaries = [
333+
summary
334+
for summary in get_code_references_for_feature_flag(feature)
335+
if summary.code_references
336+
]
337+
if not summaries:
338+
return Response(
339+
data={"detail": "No code references found for this feature."},
340+
status=status.HTTP_400_BAD_REQUEST,
341+
)
342+
343+
issue_title = CLEANUP_ISSUE_TITLE % feature.name
344+
345+
for summary in summaries:
346+
# Format code references as markdown list.
347+
references_text = "\n".join(
348+
f"- [`{ref.file_path}#L{ref.line_number}`]({ref.permalink})"
349+
for ref in summary.code_references
350+
)
351+
issue_body = CLEANUP_ISSUE_BODY % (feature.name, references_text)
352+
353+
# Parse owner/name from repository_url.
354+
url_parts = summary.repository_url.rstrip("/").split("/")
355+
owner = url_parts[-2]
356+
repo = url_parts[-1]
357+
358+
api_url = _get_github_api_url(summary.repository_url)
359+
github_response = create_github_issue(
360+
github_pat=github_pat,
361+
api_url=api_url,
362+
owner=owner,
363+
repo=repo,
364+
title=issue_title,
365+
body=issue_body,
366+
)
367+
368+
# Link the issue to the feature.
369+
issue_url: str = github_response["html_url"]
370+
metadata = json.dumps(
371+
{
372+
"title": github_response["title"],
373+
"state": github_response["state"],
374+
}
375+
)
376+
try:
377+
FeatureExternalResource.objects.create(
378+
url=issue_url,
379+
type=ExternalResourceType.GITHUB_ISSUE,
380+
feature=feature,
381+
metadata=metadata,
382+
)
383+
except IntegrityError:
384+
pass
385+
386+
return Response(status=status.HTTP_204_NO_CONTENT)
387+
388+
273389
@api_view(["POST"])
274390
@permission_classes([AllowAny])
275391
def github_webhook(request) -> Response: # type: ignore[no-untyped-def]

api/organisations/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from integrations.github.views import (
1212
GithubConfigurationViewSet,
1313
GithubRepositoryViewSet,
14+
create_cleanup_issue,
1415
fetch_issues,
1516
fetch_pull_requests,
1617
fetch_repo_contributors,
@@ -147,6 +148,11 @@
147148
fetch_repositories,
148149
name="get-github-installation-repos",
149150
),
151+
path(
152+
"<int:organisation_pk>/github/create-cleanup-issue/",
153+
create_cleanup_issue,
154+
name="create-github-cleanup-issue",
155+
),
150156
path(
151157
"<int:organisation_pk>/api-usage-notification/",
152158
OrganisationAPIUsageNotificationView.as_view(),

0 commit comments

Comments
 (0)