|
3 | 3 | import re |
4 | 4 | from functools import wraps |
5 | 5 | from typing import Any, Callable |
| 6 | +from urllib.parse import urlparse |
6 | 7 |
|
7 | 8 | import requests |
8 | 9 | from django.conf import settings |
|
13 | 14 | from rest_framework.permissions import AllowAny, IsAuthenticated |
14 | 15 | from rest_framework.response import Response |
15 | 16 |
|
| 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 |
16 | 24 | from integrations.github.client import ( |
17 | 25 | ResourceType, |
18 | 26 | create_flagsmith_flag_label, |
| 27 | + create_github_issue, |
19 | 28 | delete_github_installation, |
20 | 29 | fetch_github_repo_contributors, |
21 | 30 | fetch_github_repositories, |
22 | 31 | fetch_search_github_resource, |
23 | 32 | ) |
| 33 | +from integrations.github.constants import ( |
| 34 | + CLEANUP_ISSUE_BODY, |
| 35 | + CLEANUP_ISSUE_TITLE, |
| 36 | +) |
24 | 37 | from integrations.github.exceptions import DuplicateGitHubIntegration |
25 | 38 | from integrations.github.github import ( |
26 | 39 | handle_github_webhook_event, |
|
30 | 43 | from integrations.github.models import GithubConfiguration, GitHubRepository |
31 | 44 | from integrations.github.permissions import HasPermissionToGithubConfiguration |
32 | 45 | from integrations.github.serializers import ( |
| 46 | + CreateCleanupIssueSerializer, |
33 | 47 | GithubConfigurationSerializer, |
34 | 48 | GithubRepositorySerializer, |
35 | 49 | IssueQueryParamsSerializer, |
36 | 50 | PaginatedQueryParamsSerializer, |
37 | 51 | RepoQueryParamsSerializer, |
38 | 52 | ) |
39 | 53 | from organisations.permissions.permissions import GithubIsAdminOrganisation |
| 54 | +from projects.code_references.services import get_code_references_for_feature_flag |
40 | 55 |
|
41 | 56 | logger = logging.getLogger(__name__) |
42 | 57 |
|
@@ -270,6 +285,107 @@ def fetch_repo_contributors(request, organisation_pk) -> Response: # type: igno |
270 | 285 | ) |
271 | 286 |
|
272 | 287 |
|
| 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 | + |
273 | 389 | @api_view(["POST"]) |
274 | 390 | @permission_classes([AllowAny]) |
275 | 391 | def github_webhook(request) -> Response: # type: ignore[no-untyped-def] |
|
0 commit comments