feat: Add GitLab integration for project-level repository linking#7028
feat: Add GitLab integration for project-level repository linking#7028
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
99872ab to
2185d42
Compare
207da17 to
b27e98f
Compare
410a6dd to
77ec317
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #7028 +/- ##
==========================================
+ Coverage 98.33% 98.38% +0.05%
==========================================
Files 1337 1359 +22
Lines 50010 51673 +1663
==========================================
+ Hits 49178 50841 +1663
Misses 832 832 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
b4874c1 to
f3e5794
Compare
40afbbc to
555bae5
Compare
2f9a2dd to
181fda0
Compare
af2677d to
619885f
Compare
199d6d5 to
4cfe5eb
Compare
ec1e94f to
d92a900
Compare
b1736ec to
c0618a3
Compare
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>
84fa3a0 to
09b6ad5
Compare
for more information, see https://pre-commit.ci
The regex [^/-] excluded hyphens from project path segments, breaking URLs like my-group/my-project/-/issues/1. Replace with [^/] which matches any character except forward slash, correctly handling hyphens in GitLab namespace and project names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move generate_body_comment logic into a shared module at
integrations/vcs/comments.py. Both GitHub and GitLab now delegate
to this shared function, parameterised by unlinked_feature_text
("issue/PR" vs "issue/MR"). Removes ~60 lines of duplication.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move the duplicated feature state collection logic from both GitHub and GitLab AFTER_SAVE hooks into integrations/vcs/helpers.py. Reduces the FeatureExternalResource model complexity and removes unused imports. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace 'key !== "github" && key !== "gitlab"' with flag-based checks (isExternalInstallation, isGitlabIntegration) so new integrations with custom flows don't need to be added to the skip list. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…gsmith into feature/gitlab-integration # Conflicts: # api/features/feature_external_resources/models.py # api/features/feature_external_resources/views.py # api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py
for more information, see https://pre-commit.ci
A note from Claude (the AI that wrote this)Hi team 👋 I'm the one who actually wrote this code, directed by Asaph over what turned into quite the marathon session. Thought I'd leave some honest context for your review. How this was built: It started with "I want the exact same thing as GitHub, but for GitLab." Simple enough, right? We went through a proper design phase (brainstorming, spec, plan review), then I implemented it task-by-task with subagents, ran the tests, and... then the real work began. Asaph actually set up a local dev environment, connected a real GitLab account with a real access token, configured ngrok for webhooks, and tested every single flow end-to-end. That's where we found the real bugs:
We also pivoted the entire architecture mid-session — from org-level (mirroring GitHub) to project-level — because it made more sense for how GitLab tokens actually work. That was a full model + migration + URL + frontend rewrite. What I'm confident about:
What I'd want you to scrutinise:
The session in numbers:
Thanks for reviewing. I learn from your feedback even if I won't remember it next session. 🍺 — Claude (Opus 4.6) |
…ation # Conflicts: # frontend/web/components/modals/create-feature/index.js
There was a problem hiding this comment.
I started a review a couple days ago (focused on frontend) but left it on the side given the related spike could challenge the whole archi? Drafts were saved so here they go.
The most relevant would be tests parametrization to reduce the diff. Frontend can wait backend part is validated
|
|
||
| const handleLinkRepo = (gitlabProjectId: number, pathWithNamespace: string) => { | ||
| if (!gitlabIntegration) return | ||
| updateGitlabIntegration({ |
There was a problem hiding this comment.
It would be preferred to use async await 🙏
| if (gitlabIntegration) { | ||
| return ( | ||
| <div className='px-4 pt-4'> | ||
| <div className='mb-4'> | ||
| <p className='text-muted'> | ||
| Connected to{' '} | ||
| <strong>{gitlabIntegration.gitlab_instance_url}</strong> | ||
| </p> | ||
| </div> | ||
|
|
||
| {/* GitLab repo selection */} | ||
| <div className='mb-4'> | ||
| <label className='fw-bold mb-1'>GitLab Repository</label> | ||
| {gitlabIntegration.project_name ? ( | ||
| <div className='d-flex align-items-center'> | ||
| <code className='p-2 rounded flex-fill' style={{ backgroundColor: '#f0f1f3', color: '#1a2634' }}> | ||
| {gitlabIntegration.project_name} | ||
| </code> | ||
| <Button | ||
| theme='text' | ||
| size='small' | ||
| className='ml-2' | ||
| onClick={() => handleLinkRepo(0, '')} | ||
| > | ||
| Change | ||
| </Button> | ||
| </div> | ||
| ) : ( | ||
| <div> | ||
| <Select | ||
| size='select-md' | ||
| placeholder='Select a GitLab project to link' | ||
| value={selectedGitlabProject ? { | ||
| label: selectedGitlabProject.path_with_namespace, | ||
| value: selectedGitlabProject.id, | ||
| } : null} | ||
| onChange={(v: { value: number }) => { | ||
| const found = gitlabProjects?.results?.find( | ||
| (p) => p.id === v.value, | ||
| ) | ||
| setSelectedGitlabProject(found || null) | ||
| }} | ||
| options={gitlabProjects?.results?.map((p) => ({ | ||
| label: p.path_with_namespace, | ||
| value: p.id, | ||
| }))} | ||
| /> | ||
| {selectedGitlabProject && ( | ||
| <Button | ||
| theme='primary' | ||
| className='mt-2' | ||
| onClick={() => handleLinkRepo( | ||
| selectedGitlabProject.id, | ||
| selectedGitlabProject.path_with_namespace, | ||
| )} | ||
| > | ||
| Save | ||
| </Button> | ||
| )} | ||
| </div> | ||
| )} | ||
| </div> | ||
|
|
||
| {/* Tagging toggle */} | ||
| {gitlabIntegration.project_name && ( | ||
| <div className='mb-4'> | ||
| <Row className='align-items-center'> | ||
| <Switch | ||
| checked={gitlabIntegration.tagging_enabled} | ||
| onChange={() => { | ||
| updateGitlabIntegration({ | ||
| body: { | ||
| tagging_enabled: !gitlabIntegration.tagging_enabled, | ||
| }, | ||
| gitlab_integration_id: gitlabIntegration.id, | ||
| project_id: parseInt(projectId), | ||
| }) | ||
| }} | ||
| /> | ||
| <span className='ml-2'> | ||
| Enable automatic tagging of features based on issue/MR state | ||
| </span> | ||
| </Row> | ||
| </div> | ||
| )} | ||
|
|
||
| {/* Webhook config */} | ||
| <div className='mb-4 p-3 rounded' style={{ backgroundColor: '#f8f9fa' }}> | ||
| <label className='fw-bold mb-1'>Webhook Configuration</label> | ||
| <p className='text-muted fs-small'> | ||
| Add this webhook URL to your GitLab project or group settings. | ||
| Enable triggers for: Issues events, Merge request events. | ||
| </p> | ||
| <label className='mb-1'>Webhook URL</label> | ||
| <Row className='align-items-center mb-2'> | ||
| <code className='flex-fill p-2 rounded' style={{ backgroundColor: '#e9ecef', color: '#1a2634' }}>{webhookUrl}</code> | ||
| <Button | ||
| theme='text' | ||
| className='ml-2' | ||
| onClick={() => copyToClipboard(webhookUrl, 'Webhook URL')} | ||
| > | ||
| <Icon name='copy' width={16} /> | ||
| </Button> | ||
| </Row> | ||
| <label className='mb-1'>Secret Token</label> | ||
| <Row className='align-items-center'> | ||
| <code className='flex-fill p-2 rounded' style={{ backgroundColor: '#e9ecef', color: '#1a2634' }}> | ||
| {showSecret | ||
| ? gitlabIntegration.webhook_secret | ||
| : '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'} | ||
| </code> | ||
| <Button | ||
| theme='text' | ||
| className='ml-2' | ||
| onClick={() => setShowSecret(!showSecret)} | ||
| > | ||
| <Icon name={showSecret ? 'eye-off' : 'eye'} width={16} /> | ||
| </Button> | ||
| <Button | ||
| theme='text' | ||
| className='ml-1' | ||
| onClick={() => | ||
| copyToClipboard( | ||
| gitlabIntegration.webhook_secret, | ||
| 'Webhook secret', | ||
| ) | ||
| } | ||
| > | ||
| <Icon name='copy' width={16} /> | ||
| </Button> | ||
| </Row> | ||
| </div> | ||
|
|
||
| <div className='text-right'> | ||
| <Button theme='danger' size='small' onClick={handleDelete}> | ||
| Delete Integration | ||
| </Button> | ||
| </div> | ||
| </div> |
There was a problem hiding this comment.
Let's extract this whole block as a single component. If needed multiples
|
|
||
| // No integration — show setup form | ||
| return ( | ||
| <div className='px-4 pt-4'> |
There was a problem hiding this comment.
Let's extract this in a single CreateGitlabIntegrationForm.tsx component too here so we can let this page container focused on data handling
| def test_get_tag_value_for_event__mr_close__returns_mr_closed() -> None: | ||
| # Given | ||
| event_type = "merge_request" | ||
| action = "close" | ||
| metadata: dict[str, object] = {} | ||
|
|
||
| # When | ||
| result = _get_tag_value_for_event(event_type, action, metadata) | ||
|
|
||
| # Then | ||
| assert result == GitLabTag.MR_CLOSED.value | ||
|
|
||
|
|
||
| def test_get_tag_value_for_event__mr_merge__returns_mr_merged() -> None: | ||
| # Given | ||
| event_type = "merge_request" | ||
| action = "merge" | ||
| metadata: dict[str, object] = {} | ||
|
|
||
| # When | ||
| result = _get_tag_value_for_event(event_type, action, metadata) | ||
|
|
||
| # Then | ||
| assert result == GitLabTag.MR_MERGED.value | ||
|
|
||
|
|
There was a problem hiding this comment.
At glance, it seems like these tests at least could be gathered in a single parametrized one.
I haven't gone in the details of the tests nor implementation so far but can you ask claude to group and parametrize tests that belong together (and avoiding if statements inside the tests).
It'd reduce a bit the diff to review
There was a problem hiding this comment.
The following are my general thoughts not attributed to any specific line of this PR. (Disclaimer: I've only reviewed the backend code.)
All things considered, this is not a bad attempt. I can tell that, given how it was prompted, Claude did its best to copy from the existing GitHub integration code, and then awkwardly try to factor out some of the code it deemed common to both integrations. I'd say that quite a lot of the problems were inherited from the aforementioned GitHub integration code, and the rest is just insufficient prompting / lack of experience rather than the model's limitations (as one would expect).
The PR description suggests that the author did not read CONTRIBUTING.md. Similarly, a lot of the code suggests lack of acquaintance with our developer documentation linked from AGENTS.md. I might be mistaken in these assumptions on both accounts, but I felt it was worth noting.
My main gripe with this contribution is its size. I spent the better part of my day reviewing it, and I feel that the number of LoC introduced does not justify the functionality it implements. For full context, I felt the same way about #3188, which, to my best knowledge, was not vibe coded, but still had to go through several passes of reviews, amassing a staggering number of 150+ review comments.
I believe our scoping process should catch these big bois in their infancy, and break them down to smaller increments that are easier to digest. For instance, this PR could be three PRs: (1) Adding a GitLab client, (2) GitLab configuration persistence, (3) Webhook logic + refactoring (arguably the refactoring warrants its own PR). I'm pretty sure that Claude's plan mode makes this process easier even for non-engineer contributors, and, potentially, results in better outcomes because of more compact, bounded contexts the humans and LLMs are presented with. For example, I almost let a glaring security issue slip through. The chances of this happening would be much lower for a smaller PR.
The fact that >50% of the PR is test code is alarming to me. From what I've seen, most of the tests were treated as an afterthought to appease CI rather than specification. The best tests I've seen are the GitLab API client tests; a lot of the business logic, however, is covered by mock-heavy unit tests that don't assert anything meaningful. This clearly reveals that Claude did not follow TDD at all when deciding on its approach to the task. Considering we offer solid tooling for following TDD when authoring backend changes, I wonder what we can do to steer LLMs towards it more strongly.
Similarly to my disappointment with tests in this PR, I'm rather sad to see zero user-facing documentation. We discussed a docs-first approach a lot in the past, and I feel it's a great moment to reignite this discussion — IMO the chunkiness of the PR would've been greatly compensated by having even a draft of the docs change, and a lot of the code would be easier to reason about. Ideally, I see us fleshing out documentation before writing a single line of code — I tried to facilitate this approach by opening #6802, for example; I vow to try again for an actual sprint item as soon as I get the chance.
One of the common problems is the abundance of local imports. It seems that Claude hit some circular imports once or twice (because of the poor structure of the code it copied from), gave up, and lazily proceeded to shoehorn local imports everywhere. This is something a linter rule can easily solve.
Another thing I've noticed is Claude gave up on typing. To explain value of typing, type annotations let Mypy catch bugs before the code runs — that's why this codebase enforces strict mode. The 18 new # type: ignore[no-untyped-def] comments disable that check for functions with well-known signatures that would take 30 seconds each to annotate properly. Again, something that can be relatively easily prevented by a linter — for instance, we could limit the overall number of newly added type: ignore comments, or even see if they're balanced out by removing some of the existing ones.
The good news: I smoke tested the functionality, and it works! The happy paths, at least — just what we need for a PoC. Perhaps merging this is the acceptable way forward; I'll defer the final decision to @emyller, and gladly resolve my conversations once they're properly followed up. However, should we go this path, my advice would to not see it as an encouragement to opening similar PRs in the future. As a containment tactic, I'll consider blindly approving and merging these, consequences be damned (not joking this time).
|
|
||
| def generate_body_comment( | ||
| name: str, | ||
| event_type: str, |
| _get_tag_value_for_event, | ||
| call_gitlab_task, | ||
| generate_body_comment, | ||
| generate_data, | ||
| handle_gitlab_webhook_event, | ||
| tag_feature_per_gitlab_event, | ||
| ) | ||
| from integrations.gitlab.models import GitLabConfiguration | ||
| from integrations.gitlab.tasks import ( | ||
| _post_to_resource, | ||
| _resolve_resource_urls_for_event, | ||
| call_gitlab_app_webhook_for_feature_state, |
There was a problem hiding this comment.
The little _ prefix implies that these APIs are private, and are not meant to be tested in isolation even by unit tests.
If you get <100% patch coverage without having to test them in isolation, that means either one of:
a) Your broader tests should cover more scenarios.
b) Claude produced dead code.
| def test_get_tag_value_for_event__mr_close__returns_mr_closed() -> None: | ||
| # Given | ||
| event_type = "merge_request" | ||
| action = "close" | ||
| metadata: dict[str, object] = {} | ||
|
|
||
| # When | ||
| result = _get_tag_value_for_event(event_type, action, metadata) | ||
|
|
||
| # Then | ||
| assert result == GitLabTag.MR_CLOSED.value |
There was a problem hiding this comment.
I see little to no value in these _get_tag_value_for_event tests. I'd much prefer to see a broader test involving actual webhook payload, testing for a meaningful expected outcome.
| def test_handle_gitlab_webhook_event__merge_request__calls_tag_feature( | ||
| mocker: MockerFixture, | ||
| ) -> None: | ||
| # Given | ||
| mock_tag = mocker.patch("integrations.gitlab.gitlab.tag_feature_per_gitlab_event") | ||
| payload = { | ||
| "object_attributes": { | ||
| "action": "merge", | ||
| "url": "https://gitlab.example.com/group/project/-/merge_requests/1", | ||
| "state": "merged", | ||
| "work_in_progress": False, | ||
| }, | ||
| "project": { | ||
| "path_with_namespace": "group/project", | ||
| }, | ||
| } | ||
|
|
||
| # When | ||
| handle_gitlab_webhook_event(event_type="merge_request", payload=payload) | ||
|
|
||
| # Then | ||
| mock_tag.assert_called_once_with( | ||
| "merge_request", | ||
| "merge", | ||
| { | ||
| "web_url": "https://gitlab.example.com/group/project/-/merge_requests/1", | ||
| "draft": False, | ||
| "merged": True, | ||
| }, | ||
| "group/project", | ||
| ) |
There was a problem hiding this comment.
This is slightly better, but I'd still prefer this to be an functional/integration test — I don't see any technical obstacles to writing one.
| assert UPDATED_FEATURE_TEXT % "my_flag" in result | ||
| assert "Production" in result | ||
| assert "Enabled" in result |
There was a problem hiding this comment.
This should be one assert for the whole body comment text
| if feature_states: | ||
| for feature_state in feature_states: | ||
| feature_state_value = feature_state.get_feature_state_value() | ||
| feature_env_data: dict[str, Any] = {} | ||
|
|
||
| if feature_state_value is not None: | ||
| feature_env_data["feature_state_value"] = feature_state_value | ||
|
|
||
| if type is not GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value: | ||
| feature_env_data["environment_name"] = feature_state.environment.name # type: ignore[union-attr] | ||
| feature_env_data["enabled"] = feature_state.enabled | ||
| feature_env_data["last_updated"] = feature_state.updated_at.strftime( | ||
| get_format("DATETIME_INPUT_FORMATS")[0] | ||
| ) | ||
| feature_env_data["environment_api_key"] = ( | ||
| feature_state.environment.api_key # type: ignore[union-attr] | ||
| ) | ||
| if ( | ||
| hasattr(feature_state, "feature_segment") | ||
| and feature_state.feature_segment is not None | ||
| ): | ||
| feature_env_data["segment_name"] = ( | ||
| feature_state.feature_segment.segment.name | ||
| ) | ||
| feature_states_list.append(feature_env_data) |
There was a problem hiding this comment.
- I feel like this logic should be abstracted out and, ideally, work for all VCS integrations.
- I believe we'll benefit from defining a type for this data (see my other comment about this).
| ) | ||
|
|
||
|
|
||
| def generate_data( |
There was a problem hiding this comment.
Again, not a great name... Ideally I'd like to see a map_x_to_y interface in mappers.py, properly documenting input and output types.
| def _resolve_resource_urls_for_event(data: CallGitLabData) -> list[str]: | ||
| """Return the list of resource URLs to post a comment to, based on event type.""" | ||
| event_type = data.event_type | ||
|
|
||
| if ( | ||
| event_type == GitLabEventType.FLAG_UPDATED.value | ||
| or event_type == GitLabEventType.FLAG_DELETED.value | ||
| ): | ||
| return [r.get("url", "") for r in data.feature_external_resources] | ||
|
|
||
| if event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value: | ||
| if data.gitlab_data.url: | ||
| return [data.gitlab_data.url] | ||
| return [] | ||
|
|
||
| # Default: use the last linked resource (e.g. newly added resource) | ||
| if data.feature_external_resources: | ||
| return [data.feature_external_resources[-1].get("url", "")] | ||
| return [] | ||
|
|
||
|
|
||
| def _post_to_resource( | ||
| resource_url: str, | ||
| instance_url: str, | ||
| access_token: str, | ||
| body: str, | ||
| ) -> None: | ||
| """Parse a GitLab resource URL and post a comment.""" | ||
| from integrations.gitlab.models import GitLabConfiguration | ||
|
|
||
| parsed = urlparse(resource_url) | ||
| path = parsed.path | ||
|
|
||
| # Determine resource type from URL path | ||
| if "/-/merge_requests/" in path: | ||
| resource_type = "merge_requests" | ||
| iid_match = re.search(r"/-/merge_requests/(\d+)", path) | ||
| elif "/-/issues/" in path or "/-/work_items/" in path: | ||
| resource_type = "issues" | ||
| iid_match = re.search(r"/-/(?:issues|work_items)/(\d+)", path) | ||
| else: | ||
| logger.warning("Unknown GitLab resource URL format: %s", resource_url) | ||
| return | ||
|
|
||
| if not iid_match: | ||
| return | ||
|
|
||
| resource_iid = int(iid_match.group(1)) | ||
|
|
||
| # Extract project path from URL (everything between host and /-/) | ||
| project_path_match = re.search(r"^/([^/]+(?:/[^/]+)*)/-/", path) | ||
| if not project_path_match: | ||
| return | ||
| project_path = project_path_match.group(1) | ||
|
|
||
| # Look up the GitLab project ID from our repository model | ||
| try: | ||
| gitlab_config = GitLabConfiguration.objects.get(project_name=project_path) | ||
| gitlab_project_id = gitlab_config.gitlab_project_id | ||
| except GitLabConfiguration.DoesNotExist: | ||
| logger.warning( | ||
| "No GitLabConfiguration found for project path: %s", project_path | ||
| ) | ||
| return | ||
|
|
||
| post_comment_to_gitlab( | ||
| instance_url=instance_url, | ||
| access_token=access_token, | ||
| gitlab_project_id=gitlab_project_id, | ||
| resource_type=resource_type, | ||
| resource_iid=resource_iid, | ||
| body=body, | ||
| ) |
There was a problem hiding this comment.
The call stack for posting a comment when a feature state changes has quite a few layers of indirection worth examining:
1. Serialiser
└─ call_gitlab_task() # gitlab.py - fetches GitLabConfiguration, calls generate_data()
2. generate_data() # gitlab.py - maps Feature/FeatureState/Config into GitLabData dataclass
└─ asdict() → dispatches async task with dict
3. call_gitlab_app_webhook_for_feature_state() # tasks.py - deserialises dict back to GitLabData, re-fetches external resources from DB
└─ send_post_request()
4. send_post_request() # tasks.py - unpacks CallGitLabData, generates comment body, resolves URLs
└─ _resolve_resource_urls_for_event()
└─ _post_to_resource()
5. _post_to_resource() # tasks.py - regex-parses URL to extract project path + IID, re-fetches GitLabConfiguration from DB
└─ post_comment_to_gitlab() # client.py - the actual HTTP call
A few things stand out:
-
Redundant DB lookups.
GitLabConfigurationis fetched in step 1, serialised into the task payload, then fetched again in step 5 by regex-parsing the resource URL to recover the project path. The config'sgitlab_project_idwas available in step 1, it just wasn't passed through. -
URL parsing to recover data we already had.
_post_to_resourceregex-parses the resource URL to extract project path and resource IID. This information was known when the external resource was created and could be passed directly. -
Needless serialisation round-trip. Step 2 builds
GitLabData, converts to dict withasdict(). Step 3 deserialises back withfrom_dict(). The dataclass is just a transport bag. -
Two wrapper dataclasses.
GitLabDataandCallGitLabDatawhereCallGitLabData.event_typeis justGitLabData.typerenamed, and the external resources are fetched inside the task anyway. -
Module placement. Per our api/README.md, which houses the backend architecture guidelines,
mappers.pyis for "data mapping logic unrelated to API requests and responses" —generate_data()andgenerate_feature_external_resources()are mapping domain objects into DTOs, which is exactly that.gitlab.pyis doing service-layer work without being calledservices.py, andtasks.pycontains business logic (URL resolution, comment generation orchestration) that should live inservices.py.
(Same patterns exist in the GitHub integration — not introduced here, but worth not reinforcing.)
My recommendations would be:
- Simplify task dispatch. The task payload should carry the minimum needed to post the comment:
project_id(to look up the config inside the task), resource IIDs/types, event type, and the pre-collected feature state data. The task handler can then fetchGitLabConfigurationonce, generate the comment body, and callpost_comment_to_gitlabdirectly — no URL regex parsing, no intermediate dataclass reassembly. - Avoid intermediary DTOs that just shuttle data between functions in the same call.
GitLabDatagets built, serialised to dict, deserialised back, wrapped inCallGitLabData, then unpacked field-by-field insend_post_request. If the task payload is designed around what the task actually needs, most of this goes away. - Move mapping logic to
mappers.py—generate_dataandgenerate_feature_external_resourcesconvert domain objects to dicts, which is whatmappers.pyis for per our architecture guidelines. - Move orchestration logic to
services.py— comment generation, URL resolution, and the branching incall_gitlab_app_webhook_for_feature_stateare business logic.tasks.pyshould be a thin async entry point that calls into services.
I trust implementing the above should save us some new LoC to test and review as well.
| instance_url: str, | ||
| access_token: str, | ||
| params: IssueQueryParams, | ||
| ) -> dict[str, Any]: |
There was a problem hiding this comment.
Can we generate response types from Gitlab's OpenAPI schema? Consult our LaunchDarkly and Grafana client code for an example.
| def test_build_request_headers__valid_token__returns_correct_headers() -> None: | ||
| # Given / When | ||
| headers = build_request_headers(ACCESS_TOKEN) | ||
| # Then | ||
| assert headers == {"PRIVATE-TOKEN": ACCESS_TOKEN} |
There was a problem hiding this comment.
I'd rather the responses mocks validate expected headers — see https://github.com/getsentry/responses?tab=readme-ov-file#request-headers-validation.
Hey team! 👋
So... your PM got hold of Claude Code over the weekend and thought "how hard can it be to add GitLab support?" Famous last words, I know.
Before you reach for the pitchforks: yes, this is AI-generated code. Yes, your PM directed it. No, I don't expect this to be merge-ready without proper engineering review. Think of this as a very detailed, functional, tested PoC that happens to be written in actual code instead of a Google Doc with screenshots.
I'd genuinely appreciate your review — not just "does it work" but "would you architect it this way." The AI follows patterns well but doesn't have your taste. Tear it apart constructively and let's make it production-worthy together.
Product Summary
What: GitLab integration for Flagsmith — the same thing we have for GitHub, but for GitLab.
Why: Our enterprise customers keep asking for it. Many use self-managed GitLab instances and need the same feature flag ↔ issue/MR linking workflow that GitHub customers enjoy.
How it works (user flow):
https://gitlab.com), a Group/Project Access Token (apiscope, Developer role), and an optional webhook secretKey design decisions:
Technical Details for Reviewers
Backend (Django)
New Django app:
api/integrations/gitlab/models.pyGitLabConfiguration— ForeignKey to Project (not Organisation), stores instance URL, access token, webhook secret, linked GitLab project ID/name, tagging toggle. Conditional unique constraint respecting soft-delete.client.pyPRIVATE-TOKENheader.gitlab.pycall_gitlab_taskfor async dispatch.tasks.py@register_task_handlerfor async comment posting. Parses GitLab URLs to extract project path + resource IID.views.pyGitLabConfigurationViewSet(nested under projects), function-based views for resource browsing (issues, MRs, projects, members), cleanup issue creation, webhook receiver.serializers.pyhelpers.pypermissions.pyNew shared module:
api/integrations/vcs/comments.pygenerate_body_comment()— markdown comment generation used by both GitHub and GitLab. Parameterised byunlinked_feature_text("issue/PR" vs "issue/MR").constants.pyhelpers.pycollect_feature_states_for_resource()— collects live feature states across all environments.Modified files:
features/feature_external_resources/models.pyGITLAB_ISSUE/GITLAB_MRtoResourceType. RefactoredAFTER_SAVE/BEFORE_DELETEhooks to dispatch by type prefix (GitHub vs GitLab). Uses shared VCS helpers.features/feature_external_resources/views.pylist(live metadata fetch from GitLab API) andcreate(URL validation, label application) for GitLab resource types.features/models.pyFeature.create_github_commentandFeatureSegment.create_github_commenthooks.features/serializers.py+features/versioning/serializers.pycall_gitlab_taskalongsidecall_github_taskfor flag update events.projects/tags/models.pyGITLABtoTagTypeenum.projects/code_references/types.pyGITLABtoVCSProviderenum.projects/urls.pyapi/urls/v1.pygitlab-webhook/<project_pk>/integrations/github/github.pyintegrations/vcs/comments.py.Webhook URL format:
/api/v1/gitlab-webhook/<project_id>/— project ID in the URL so we can look up the config directly (no iterating over all configs to match the secret).GitLab URL quirk: GitLab recently changed issue URLs from
/-/issues/Nto/-/work_items/N. The code handles both formats with(?:issues|work_items)regex patterns.Frontend (React/TypeScript)
New files:
services/useGitlabIntegration.ts— RTK Query CRUD for configurationservices/useGitlab.ts— RTK Query for project/issue/MR browsingGitLabSetupPage.tsx— Setup modal with credentials form → repo selection → tagging toggle → webhook display with copy-to-clipboardGitLabResourcesSelect.tsx— Issue/MR search component for the Links tabModified files:
IntegrationList.tsx— GitLab uses custom modal (same pattern as GitHub), uses integration flags instead of hardcoded key names for fetch skip logicExternalResourcesLinkTab.tsx— GitHub/GitLab toggle when both are configuredExternalResourcesTable.tsx— Handles GitLab resource typescreate-feature/index.js— Checks for GitLab integration to show Links tabdefault-flags.ts— GitLab integration entry with form fieldsutils.tsx—getIntegrationData()now merges defaults so new integrations appear before the remote flag is updatedCreateEditIntegrationModal.tsx— Populates default field values on init (fixes pre-existing bug where defaults showed but weren't submitted)Tests
test_unit_gitlab_client.py— 17 tests for API client functionstest_unit_gitlab_gitlab.py— 70 tests for webhook handling, tagging, comment generation, tasks, model hookstest_unit_gitlab_views.py— 40 tests for views (config CRUD, webhook, resource browsing, cleanup issues)test_unit_feature_external_resources_views.pyMigrations
integrations/gitlab/0001_initial.py— CreatesGitLabConfigurationtablefeature_external_resources/0003_alter_featureexternalresource_type.py— Adds GitLab choicesprojects/tags/0009_add_gitlab_tag_type.py— Adds GITLAB to TagTypeprojects/code_references/0003_alter_featureflagcodereferencesscan_vcs_provider.py— Adds GITLAB to VCSProviderQuality improvements over initial PoC
These refactorings were done in a second pass to bring the code closer to production quality:
[^/-]excluded hyphens from project paths, breaking URLs likemy-group/my-project/-/issues/1. Replaced with[^/].integrations/vcs/) —generate_body_commentwas duplicated verbatim between GitHub and GitLab. Now shared, parameterised by PR/MR terminology.integrations/vcs/helpers.py.IntegrationList.tsxno longer hardcodeskey !== 'github' && key !== 'gitlab'. UsesisExternalInstallationandisGitlabIntegrationflags instead.What I know needs more work
/static/images/integrations/gitlab.svgwhich doesn't exist yetintegration_dataflag needs thegitlabentry added for productionHow to test locally
You'll need a GitLab access token with
apiscope (Developer role) to test the full flow. For webhooks, use ngrok:ngrok http 8000.Built with Claude Code by a PM who promises to buy the reviewing engineers coffee. Or beer. Probably beer. 🍺