Skip to content

Commit 77ec317

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 77ec317

Some content is hidden

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

45 files changed

+3426
-105
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,
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.12 on 2026-03-24 14:34
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('feature_external_resources', '0002_featureexternalresource_feature_ext_type_2b2068_idx'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='featureexternalresource',
15+
name='type',
16+
field=models.CharField(choices=[('GITHUB_ISSUE', 'GitHub Issue'), ('GITHUB_PR', 'GitHub PR'), ('GITLAB_ISSUE', 'GitLab Issue'), ('GITLAB_MR', 'GitLab MR')], max_length=20),
17+
),
18+
]

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: 105 additions & 11 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,19 +61,94 @@ 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

67-
def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
68-
feature = get_object_or_404(
69-
Feature.objects.filter(
70-
id=self.kwargs["feature_pk"],
71-
),
72-
)
108+
def _create_gitlab_resource(self, request, feature, resource_type, *args, **kwargs): # type: ignore[no-untyped-def]
109+
from integrations.gitlab.models import GitLabConfiguration
110+
111+
try:
112+
gitlab_config = GitLabConfiguration.objects.get(
113+
project=feature.project,
114+
deleted_at__isnull=True,
115+
)
116+
except GitLabConfiguration.DoesNotExist:
117+
return Response(
118+
data={
119+
"detail": "This Project doesn't have a valid GitLab integration configuration"
120+
},
121+
content_type="application/json",
122+
status=status.HTTP_400_BAD_REQUEST,
123+
)
124+
125+
url = request.data.get("url")
126+
if resource_type == "GITLAB_MR":
127+
pattern = r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/merge_requests/(\d+)$"
128+
else:
129+
pattern = r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/(?:issues|work_items)/(\d+)$"
73130

131+
url_match = re.search(pattern, url)
132+
if url_match:
133+
_project_path, resource_iid = url_match.groups()
134+
api_resource_type = "merge_requests" if resource_type == "GITLAB_MR" else "issues"
135+
if gitlab_config.tagging_enabled and gitlab_config.gitlab_project_id:
136+
label_gitlab_issue_mr(
137+
instance_url=gitlab_config.gitlab_instance_url,
138+
access_token=gitlab_config.access_token,
139+
gitlab_project_id=gitlab_config.gitlab_project_id,
140+
resource_type=api_resource_type,
141+
resource_iid=int(resource_iid),
142+
)
143+
return super().create(request, *args, **kwargs)
144+
else:
145+
return Response(
146+
data={"detail": "Invalid GitLab Issue/MR URL"},
147+
content_type="application/json",
148+
status=status.HTTP_400_BAD_REQUEST,
149+
)
150+
151+
def _create_github_resource(self, request, feature, resource_type, *args, **kwargs): # type: ignore[no-untyped-def]
74152
github_configuration = (
75153
Organisation.objects.prefetch_related("github_config")
76154
.get(id=feature.project.organisation_id)
@@ -88,9 +166,9 @@ def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
88166

89167
# Get repository owner and name, and issue/PR number from the external resource URL
90168
url = request.data.get("url")
91-
if request.data.get("type") == "GITHUB_PR":
169+
if resource_type == "GITHUB_PR":
92170
pattern = r"github.com/([^/]+)/([^/]+)/pull/(\d+)$"
93-
elif request.data.get("type") == "GITHUB_ISSUE":
171+
elif resource_type == "GITHUB_ISSUE":
94172
pattern = r"github.com/([^/]+)/([^/]+)/issues/(\d+)$"
95173
else:
96174
return Response(
@@ -122,6 +200,22 @@ def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
122200
status=status.HTTP_400_BAD_REQUEST,
123201
)
124202

203+
def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
204+
feature = get_object_or_404(
205+
Feature.objects.filter(
206+
id=self.kwargs["feature_pk"],
207+
),
208+
)
209+
210+
resource_type = request.data.get("type", "")
211+
212+
# Handle GitLab resources
213+
if resource_type in ("GITLAB_MR", "GITLAB_ISSUE"):
214+
return self._create_gitlab_resource(request, feature, resource_type, *args, **kwargs)
215+
216+
# Handle GitHub resources
217+
return self._create_github_resource(request, feature, resource_type, *args, **kwargs)
218+
125219
def perform_update(self, serializer): # type: ignore[no-untyped-def]
126220
external_resource_id = int(self.kwargs["pk"])
127221
serializer.save(id=external_resource_id)

0 commit comments

Comments
 (0)