Skip to content
Draft
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
7 changes: 7 additions & 0 deletions api/api/urls/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from features.feature_health.views import feature_health_webhook
from features.views import SDKFeatureStates, get_multivariate_options
from integrations.github.views import github_webhook
from integrations.gitlab.views import gitlab_webhook
from organisations.views import chargebee_webhook

schema_view_permission_class = ( # pragma: no cover
Expand Down Expand Up @@ -42,6 +43,12 @@
re_path(r"cb-webhook/", chargebee_webhook, name="chargebee-webhook"),
# GitHub integration webhook
re_path(r"github-webhook/", github_webhook, name="github-webhook"),
# GitLab integration webhook
re_path(
r"gitlab-webhook/(?P<project_pk>\d+)/",
gitlab_webhook,
name="gitlab-webhook",
),
re_path(r"cb-webhook/", chargebee_webhook, name="chargebee-webhook"),
# Feature health webhook
re_path(
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 @@ -154,6 +154,7 @@
"integrations.flagsmith",
"integrations.launch_darkly",
"integrations.github",
"integrations.gitlab",
"integrations.grafana",
# Rate limiting admin endpoints
"axes",
Expand Down
14 changes: 14 additions & 0 deletions api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
from features.versioning.tasks import enable_v2_versioning
from features.workflows.core.models import ChangeRequest
from integrations.github.models import GithubConfiguration, GitHubRepository
from integrations.gitlab.models import GitLabConfiguration
from metadata.models import (
Metadata,
MetadataField,
Expand Down Expand Up @@ -1219,6 +1220,19 @@ def github_repository(
)


@pytest.fixture()
def gitlab_configuration(project: Project) -> GitLabConfiguration:
return GitLabConfiguration.objects.create( # type: ignore[no-any-return]
project=project,
gitlab_instance_url="https://gitlab.example.com",
access_token="test-gitlab-token",
webhook_secret="test-webhook-secret",
gitlab_project_id=1,
project_name="testgroup/testrepo",
tagging_enabled=True,
)


@pytest.fixture(params=AdminClientAuthType.__args__) # type: ignore[attr-defined]
def admin_client_auth_type(
request: pytest.FixtureRequest,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.12 on 2026-03-24 14:34

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('feature_external_resources', '0002_featureexternalresource_feature_ext_type_2b2068_idx'),
]

operations = [
migrations.AlterField(
model_name='featureexternalresource',
name='type',
field=models.CharField(choices=[('GITHUB_ISSUE', 'GitHub Issue'), ('GITHUB_PR', 'GitHub PR'), ('GITLAB_ISSUE', 'GitLab Issue'), ('GITLAB_MR', 'GitLab MR')], max_length=20),
),
]
127 changes: 96 additions & 31 deletions api/features/feature_external_resources/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,16 @@
import re

from django.db import models
from django.db.models import Q
from django_lifecycle import ( # type: ignore[import-untyped]
AFTER_SAVE,
BEFORE_DELETE,
LifecycleModelMixin,
hook,
)

from environments.models import Environment
from features.models import Feature, FeatureState
from features.models import Feature
from integrations.github.constants import GitHubEventType, GitHubTag
from integrations.github.github import call_github_task
from integrations.github.models import GitHubRepository
from integrations.gitlab.constants import GitLabEventType, GitLabTag
from organisations.models import Organisation
from projects.tags.models import Tag, TagType

Expand All @@ -26,6 +23,9 @@ class ResourceType(models.TextChoices):
# GitHub external resource types
GITHUB_ISSUE = "GITHUB_ISSUE", "GitHub Issue"
GITHUB_PR = "GITHUB_PR", "GitHub PR"
# GitLab external resource types
GITLAB_ISSUE = "GITLAB_ISSUE", "GitLab Issue"
GITLAB_MR = "GITLAB_MR", "GitLab MR"


tag_by_type_and_state = {
Expand All @@ -39,6 +39,15 @@ class ResourceType(models.TextChoices):
"merged": GitHubTag.PR_MERGED.value,
"draft": GitHubTag.PR_DRAFT.value,
},
ResourceType.GITLAB_ISSUE.value: {
"opened": GitLabTag.ISSUE_OPEN.value,
"closed": GitLabTag.ISSUE_CLOSED.value,
},
ResourceType.GITLAB_MR.value: {
"opened": GitLabTag.MR_OPEN.value,
"closed": GitLabTag.MR_CLOSED.value,
"merged": GitLabTag.MR_MERGED.value,
},
}


Expand Down Expand Up @@ -67,12 +76,18 @@ class Meta:

@hook(AFTER_SAVE)
def execute_after_save_actions(self): # type: ignore[no-untyped-def]
# Tag the feature with the external resource type
metadata = json.loads(self.metadata) if self.metadata else {}
state = metadata.get("state", "open")

# Add a comment to GitHub Issue/PR when feature is linked to the GH external resource
# and tag the feature with the corresponding tag if tagging is enabled
if self.type in (ResourceType.GITHUB_ISSUE, ResourceType.GITHUB_PR):
self._handle_github_after_save(state)
elif self.type in (ResourceType.GITLAB_ISSUE, ResourceType.GITLAB_MR):
self._handle_gitlab_after_save(state)

def _handle_github_after_save(self, state: str) -> None:
from integrations.github.github import call_github_task
from integrations.github.models import GitHubRepository

if (
github_configuration := Organisation.objects.prefetch_related(
"github_config"
Expand Down Expand Up @@ -104,23 +119,13 @@ def execute_after_save_actions(self): # type: ignore[no-untyped-def]
self.feature.tags.add(github_tag)
self.feature.save()

feature_states: list[FeatureState] = []
from integrations.vcs.helpers import collect_feature_states_for_resource

environments = Environment.objects.filter(
project_id=self.feature.project_id
feature_states = collect_feature_states_for_resource(
feature_id=self.feature_id,
project_id=self.feature.project_id,
)

for environment in environments:
q = Q(
feature_id=self.feature_id,
identity__isnull=True,
)
feature_states.extend(
FeatureState.objects.get_live_feature_states(
environment=environment, additional_filters=q
)
)

call_github_task(
organisation_id=self.feature.project.organisation_id, # type: ignore[arg-type]
type=GitHubEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value,
Expand All @@ -130,17 +135,77 @@ def execute_after_save_actions(self): # type: ignore[no-untyped-def]
feature_states=feature_states,
)

def _handle_gitlab_after_save(self, state: str) -> None:
from integrations.gitlab.gitlab import call_gitlab_task
from integrations.gitlab.models import GitLabConfiguration

try:
gitlab_config = GitLabConfiguration.objects.get(
project=self.feature.project,
deleted_at__isnull=True,
)
except GitLabConfiguration.DoesNotExist:
return

if gitlab_config.tagging_enabled:
gitlab_tag, _ = Tag.objects.get_or_create(
label=tag_by_type_and_state[self.type][state],
project=self.feature.project,
is_system_tag=True,
type=TagType.GITLAB.value,
)
self.feature.tags.add(gitlab_tag)
self.feature.save()

from integrations.vcs.helpers import collect_feature_states_for_resource

feature_states = collect_feature_states_for_resource(
feature_id=self.feature_id,
project_id=self.feature.project_id,
)

call_gitlab_task(
project_id=self.feature.project_id,
type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value,
feature=self.feature,
segment_name=None,
url=None,
feature_states=feature_states,
)

@hook(BEFORE_DELETE) # type: ignore[misc]
def execute_before_save_actions(self) -> None:
# Add a comment to GitHub Issue/PR when feature is unlinked to the GH external resource
if (
Organisation.objects.prefetch_related("github_config")
.get(id=self.feature.project.organisation_id)
.github_config.first()
):
call_github_task(
organisation_id=self.feature.project.organisation_id, # type: ignore[arg-type]
type=GitHubEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value,
if self.type in (ResourceType.GITHUB_ISSUE, ResourceType.GITHUB_PR):
from integrations.github.github import call_github_task

if (
Organisation.objects.prefetch_related("github_config")
.get(id=self.feature.project.organisation_id)
.github_config.first()
):
call_github_task(
organisation_id=self.feature.project.organisation_id, # type: ignore[arg-type]
type=GitHubEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value,
feature=self.feature,
segment_name=None,
url=self.url,
feature_states=None,
)
elif self.type in (ResourceType.GITLAB_ISSUE, ResourceType.GITLAB_MR):
from integrations.gitlab.gitlab import call_gitlab_task
from integrations.gitlab.models import GitLabConfiguration

try:
GitLabConfiguration.objects.get(
project=self.feature.project,
deleted_at__isnull=True,
)
except GitLabConfiguration.DoesNotExist:
return

call_gitlab_task(
project_id=self.feature.project_id,
type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value,
feature=self.feature,
segment_name=None,
url=self.url,
Expand Down
Loading
Loading