Skip to content
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