Skip to content

Commit 2185d42

Browse files
asaphkoclaude
andcommitted
feat: add GitLab integration for project-level repository linking
Add GitLab integration to Flagsmith, allowing users to link GitLab issues and merge requests to feature flags. Supports both GitLab.com and self-managed instances via Group/Project Access Tokens. ## Backend - New Django app `integrations/gitlab/` with models, views, client, webhook handling, async tasks, and serialisers - `GitLabConfiguration` model (per-project) stores instance URL, access token, webhook secret, and linked GitLab project - Webhook receiver at `/api/v1/gitlab-webhook/<project_id>/` handles merge request and issue events for automatic feature tagging - Comment posting to GitLab issues/MRs when feature flags change - Extend `FeatureExternalResource` with GITLAB_ISSUE and GITLAB_MR resource types, with lifecycle hooks dispatching to GitHub or GitLab - Add `GITLAB` to `TagType` enum for feature tagging ## Frontend - RTK Query services for GitLab integration and resource browsing - GitLabSetupPage component with credentials form, repo selection, tagging toggle, and webhook URL display with copy-to-clipboard - GitLabResourcesSelect for linking issues/MRs to feature flags - Extend IntegrationList, ExternalResourcesLinkTab, and ExternalResourcesTable to support GitLab alongside GitHub Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 26fc48f commit 2185d42

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+3363
-99
lines changed

api/api/urls/v1.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from features.feature_health.views import feature_health_webhook
1212
from features.views import SDKFeatureStates, get_multivariate_options
1313
from integrations.github.views import github_webhook
14+
from integrations.gitlab.views import gitlab_webhook
1415
from organisations.views import chargebee_webhook
1516

1617
schema_view_permission_class = ( # pragma: no cover
@@ -42,6 +43,12 @@
4243
re_path(r"cb-webhook/", chargebee_webhook, name="chargebee-webhook"),
4344
# GitHub integration webhook
4445
re_path(r"github-webhook/", github_webhook, name="github-webhook"),
46+
# GitLab integration webhook
47+
re_path(
48+
r"gitlab-webhook/(?P<project_pk>\d+)/",
49+
gitlab_webhook,
50+
name="gitlab-webhook",
51+
),
4552
re_path(r"cb-webhook/", chargebee_webhook, name="chargebee-webhook"),
4653
# Feature health webhook
4754
re_path(

api/app/settings/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@
154154
"integrations.flagsmith",
155155
"integrations.launch_darkly",
156156
"integrations.github",
157+
"integrations.gitlab",
157158
"integrations.grafana",
158159
# Rate limiting admin endpoints
159160
"axes",

api/conftest.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
from features.versioning.tasks import enable_v2_versioning
5252
from features.workflows.core.models import ChangeRequest
5353
from integrations.github.models import GithubConfiguration, GitHubRepository
54+
from integrations.gitlab.models import GitLabConfiguration
5455
from metadata.models import (
5556
Metadata,
5657
MetadataField,
@@ -1219,6 +1220,19 @@ def github_repository(
12191220
)
12201221

12211222

1223+
@pytest.fixture()
1224+
def gitlab_configuration(project: Project) -> GitLabConfiguration:
1225+
return GitLabConfiguration.objects.create(
1226+
project=project,
1227+
gitlab_instance_url="https://gitlab.example.com",
1228+
access_token="test-gitlab-token",
1229+
webhook_secret="test-webhook-secret",
1230+
gitlab_project_id=1,
1231+
project_name="testgroup/testrepo",
1232+
tagging_enabled=True,
1233+
)
1234+
1235+
12221236
@pytest.fixture(params=AdminClientAuthType.__args__) # type: ignore[attr-defined]
12231237
def admin_client_auth_type(
12241238
request: pytest.FixtureRequest,

api/features/feature_external_resources/models.py

Lines changed: 99 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@
1414
from environments.models import Environment
1515
from features.models import Feature, FeatureState
1616
from integrations.github.constants import GitHubEventType, GitHubTag
17-
from integrations.github.github import call_github_task
18-
from integrations.github.models import GitHubRepository
17+
from integrations.gitlab.constants import GitLabEventType, GitLabTag
1918
from organisations.models import Organisation
2019
from projects.tags.models import Tag, TagType
2120

@@ -26,6 +25,9 @@ class ResourceType(models.TextChoices):
2625
# GitHub external resource types
2726
GITHUB_ISSUE = "GITHUB_ISSUE", "GitHub Issue"
2827
GITHUB_PR = "GITHUB_PR", "GitHub PR"
28+
# GitLab external resource types
29+
GITLAB_ISSUE = "GITLAB_ISSUE", "GitLab Issue"
30+
GITLAB_MR = "GITLAB_MR", "GitLab MR"
2931

3032

3133
tag_by_type_and_state = {
@@ -39,6 +41,15 @@ class ResourceType(models.TextChoices):
3941
"merged": GitHubTag.PR_MERGED.value,
4042
"draft": GitHubTag.PR_DRAFT.value,
4143
},
44+
ResourceType.GITLAB_ISSUE.value: {
45+
"opened": GitLabTag.ISSUE_OPEN.value,
46+
"closed": GitLabTag.ISSUE_CLOSED.value,
47+
},
48+
ResourceType.GITLAB_MR.value: {
49+
"opened": GitLabTag.MR_OPEN.value,
50+
"closed": GitLabTag.MR_CLOSED.value,
51+
"merged": GitLabTag.MR_MERGED.value,
52+
},
4253
}
4354

4455

@@ -67,12 +78,18 @@ class Meta:
6778

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

74-
# Add a comment to GitHub Issue/PR when feature is linked to the GH external resource
75-
# and tag the feature with the corresponding tag if tagging is enabled
84+
if self.type in (ResourceType.GITHUB_ISSUE, ResourceType.GITHUB_PR):
85+
self._handle_github_after_save(state)
86+
elif self.type in (ResourceType.GITLAB_ISSUE, ResourceType.GITLAB_MR):
87+
self._handle_gitlab_after_save(state)
88+
89+
def _handle_github_after_save(self, state: str) -> None:
90+
from integrations.github.github import call_github_task
91+
from integrations.github.models import GitHubRepository
92+
7693
if (
7794
github_configuration := Organisation.objects.prefetch_related(
7895
"github_config"
@@ -130,17 +147,85 @@ def execute_after_save_actions(self): # type: ignore[no-untyped-def]
130147
feature_states=feature_states,
131148
)
132149

150+
def _handle_gitlab_after_save(self, state: str) -> None:
151+
from integrations.gitlab.gitlab import call_gitlab_task
152+
from integrations.gitlab.models import GitLabConfiguration
153+
154+
try:
155+
gitlab_config = GitLabConfiguration.objects.get(
156+
project=self.feature.project,
157+
deleted_at__isnull=True,
158+
)
159+
except GitLabConfiguration.DoesNotExist:
160+
return
161+
162+
if gitlab_config.tagging_enabled:
163+
gitlab_tag, _ = Tag.objects.get_or_create(
164+
label=tag_by_type_and_state[self.type][state],
165+
project=self.feature.project,
166+
is_system_tag=True,
167+
type=TagType.GITLAB.value,
168+
)
169+
self.feature.tags.add(gitlab_tag)
170+
self.feature.save()
171+
172+
feature_states: list[FeatureState] = []
173+
environments = Environment.objects.filter(
174+
project_id=self.feature.project_id
175+
)
176+
for environment in environments:
177+
q = Q(
178+
feature_id=self.feature_id,
179+
identity__isnull=True,
180+
)
181+
feature_states.extend(
182+
FeatureState.objects.get_live_feature_states(
183+
environment=environment, additional_filters=q
184+
)
185+
)
186+
187+
call_gitlab_task(
188+
project_id=self.feature.project_id,
189+
type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value,
190+
feature=self.feature,
191+
segment_name=None,
192+
url=None,
193+
feature_states=feature_states,
194+
)
195+
133196
@hook(BEFORE_DELETE) # type: ignore[misc]
134197
def execute_before_save_actions(self) -> None:
135-
# Add a comment to GitHub Issue/PR when feature is unlinked to the GH external resource
136-
if (
137-
Organisation.objects.prefetch_related("github_config")
138-
.get(id=self.feature.project.organisation_id)
139-
.github_config.first()
140-
):
141-
call_github_task(
142-
organisation_id=self.feature.project.organisation_id, # type: ignore[arg-type]
143-
type=GitHubEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value,
198+
if self.type in (ResourceType.GITHUB_ISSUE, ResourceType.GITHUB_PR):
199+
from integrations.github.github import call_github_task
200+
201+
if (
202+
Organisation.objects.prefetch_related("github_config")
203+
.get(id=self.feature.project.organisation_id)
204+
.github_config.first()
205+
):
206+
call_github_task(
207+
organisation_id=self.feature.project.organisation_id, # type: ignore[arg-type]
208+
type=GitHubEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value,
209+
feature=self.feature,
210+
segment_name=None,
211+
url=self.url,
212+
feature_states=None,
213+
)
214+
elif self.type in (ResourceType.GITLAB_ISSUE, ResourceType.GITLAB_MR):
215+
from integrations.gitlab.gitlab import call_gitlab_task
216+
from integrations.gitlab.models import GitLabConfiguration
217+
218+
try:
219+
GitLabConfiguration.objects.get(
220+
project=self.feature.project,
221+
deleted_at__isnull=True,
222+
)
223+
except GitLabConfiguration.DoesNotExist:
224+
return
225+
226+
call_gitlab_task(
227+
project_id=self.feature.project_id,
228+
type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value,
144229
feature=self.feature,
145230
segment_name=None,
146231
url=self.url,

api/features/feature_external_resources/views.py

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
label_github_issue_pr,
1414
)
1515
from integrations.github.models import GitHubRepository
16+
from integrations.gitlab.client import (
17+
label_gitlab_issue_mr,
18+
)
1619
from organisations.models import Organisation
1720

1821
from .models import FeatureExternalResource
@@ -58,9 +61,47 @@ def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped
5861

5962
for resource in data if isinstance(data, list) else []:
6063
if resource_url := resource.get("url"):
61-
resource["metadata"] = get_github_issue_pr_title_and_state(
62-
organisation_id=organisation_id, resource_url=resource_url
63-
)
64+
resource_type = resource.get("type", "")
65+
if resource_type.startswith("GITHUB_"):
66+
resource["metadata"] = get_github_issue_pr_title_and_state(
67+
organisation_id=organisation_id, resource_url=resource_url
68+
)
69+
elif resource_type.startswith("GITLAB_"):
70+
try:
71+
from integrations.gitlab.client import (
72+
get_gitlab_issue_mr_title_and_state as get_gitlab_metadata,
73+
)
74+
from integrations.gitlab.models import (
75+
GitLabConfiguration,
76+
)
77+
import re as _re
78+
79+
feature_obj = get_object_or_404(
80+
Feature.objects.filter(id=self.kwargs["feature_pk"]),
81+
)
82+
gitlab_config = GitLabConfiguration.objects.filter(
83+
project=feature_obj.project, deleted_at__isnull=True
84+
).first()
85+
if gitlab_config and gitlab_config.gitlab_project_id:
86+
# Parse resource IID from URL
87+
if resource_type == "GITLAB_MR":
88+
match = _re.search(r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/merge_requests/(\d+)$", resource_url)
89+
api_type = "merge_requests"
90+
else:
91+
match = _re.search(r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/(?:issues|work_items)/(\d+)$", resource_url)
92+
api_type = "issues"
93+
94+
if match:
95+
_project_path, iid = match.group(1), int(match.group(2))
96+
resource["metadata"] = get_gitlab_metadata(
97+
instance_url=gitlab_config.gitlab_instance_url,
98+
access_token=gitlab_config.access_token,
99+
gitlab_project_id=gitlab_config.gitlab_project_id,
100+
resource_type=api_type,
101+
resource_iid=iid,
102+
)
103+
except Exception:
104+
pass
64105

65106
return Response(data={"results": data})
66107

@@ -71,6 +112,53 @@ def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
71112
),
72113
)
73114

115+
resource_type = request.data.get("type", "")
116+
117+
# Handle GitLab resources
118+
if resource_type in ("GITLAB_MR", "GITLAB_ISSUE"):
119+
from integrations.gitlab.models import GitLabConfiguration
120+
121+
try:
122+
gitlab_config = GitLabConfiguration.objects.get(
123+
project=feature.project,
124+
deleted_at__isnull=True,
125+
)
126+
except GitLabConfiguration.DoesNotExist:
127+
return Response(
128+
data={
129+
"detail": "This Project doesn't have a valid GitLab integration configuration"
130+
},
131+
content_type="application/json",
132+
status=status.HTTP_400_BAD_REQUEST,
133+
)
134+
135+
url = request.data.get("url")
136+
if resource_type == "GITLAB_MR":
137+
pattern = r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/merge_requests/(\d+)$"
138+
else:
139+
pattern = r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/(?:issues|work_items)/(\d+)$"
140+
141+
url_match = re.search(pattern, url)
142+
if url_match:
143+
_project_path, resource_iid = url_match.groups()
144+
api_resource_type = "merge_requests" if resource_type == "GITLAB_MR" else "issues"
145+
if gitlab_config.tagging_enabled and gitlab_config.gitlab_project_id:
146+
label_gitlab_issue_mr(
147+
instance_url=gitlab_config.gitlab_instance_url,
148+
access_token=gitlab_config.access_token,
149+
gitlab_project_id=gitlab_config.gitlab_project_id,
150+
resource_type=api_resource_type,
151+
resource_iid=int(resource_iid),
152+
)
153+
return super().create(request, *args, **kwargs)
154+
else:
155+
return Response(
156+
data={"detail": "Invalid GitLab Issue/MR URL"},
157+
content_type="application/json",
158+
status=status.HTTP_400_BAD_REQUEST,
159+
)
160+
161+
# Handle GitHub resources
74162
github_configuration = (
75163
Organisation.objects.prefetch_related("github_config")
76164
.get(id=feature.project.organisation_id)
@@ -88,9 +176,9 @@ def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
88176

89177
# Get repository owner and name, and issue/PR number from the external resource URL
90178
url = request.data.get("url")
91-
if request.data.get("type") == "GITHUB_PR":
179+
if resource_type == "GITHUB_PR":
92180
pattern = r"github.com/([^/]+)/([^/]+)/pull/(\d+)$"
93-
elif request.data.get("type") == "GITHUB_ISSUE":
181+
elif resource_type == "GITHUB_ISSUE":
94182
pattern = r"github.com/([^/]+)/([^/]+)/issues/(\d+)$"
95183
else:
96184
return Response(

api/features/models.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,24 @@ def create_github_comment(self) -> None:
158158
feature_states=None,
159159
)
160160

161+
# GitLab comment posting
162+
from integrations.gitlab.constants import GitLabEventType
163+
from integrations.gitlab.gitlab import call_gitlab_task
164+
165+
if (
166+
self.external_resources.exists()
167+
and self.project.gitlab_project.exists()
168+
and self.deleted_at
169+
):
170+
call_gitlab_task(
171+
project_id=self.project_id,
172+
type=GitLabEventType.FLAG_DELETED.value,
173+
feature=self,
174+
segment_name=None,
175+
url=None,
176+
feature_states=None,
177+
)
178+
161179
@hook(AFTER_CREATE)
162180
def create_feature_states(self): # type: ignore[no-untyped-def]
163181
FeatureState.create_initial_feature_states_for_feature(feature=self)
@@ -437,6 +455,23 @@ def create_github_comment(self) -> None:
437455
None,
438456
)
439457

458+
# GitLab comment posting
459+
from integrations.gitlab.constants import GitLabEventType
460+
from integrations.gitlab.gitlab import call_gitlab_task
461+
462+
if (
463+
self.feature.external_resources.exists()
464+
and self.feature.project.gitlab_project.exists()
465+
):
466+
call_gitlab_task(
467+
self.feature.project_id,
468+
GitLabEventType.SEGMENT_OVERRIDE_DELETED.value,
469+
self.feature,
470+
self.segment.name,
471+
None,
472+
None,
473+
)
474+
440475

441476
class FeatureState(
442477
SoftDeleteExportableModel,

0 commit comments

Comments
 (0)