Skip to content

feat: Add GitLab integration for project-level repository linking#7028

Draft
asaphko wants to merge 9 commits intomainfrom
feature/gitlab-integration
Draft

feat: Add GitLab integration for project-level repository linking#7028
asaphko wants to merge 9 commits intomainfrom
feature/gitlab-integration

Conversation

@asaphko
Copy link
Copy Markdown
Contributor

@asaphko asaphko commented Mar 24, 2026

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):

  1. Go to Project Settings → Integrations → click Add Integration on GitLab
  2. Enter your GitLab instance URL (defaults to https://gitlab.com), a Group/Project Access Token (api scope, Developer role), and an optional webhook secret
  3. Click Save → select which GitLab project to link → enable tagging if desired
  4. Copy the webhook URL + secret and configure it in your GitLab project/group settings (Issues events + Merge request events)
  5. Now you can link GitLab issues and MRs to feature flags from the Links tab on any feature
  6. When you toggle a flag, Flagsmith posts a comment to the linked GitLab issue/MR showing the current state
  7. When an issue/MR changes state in GitLab (opened, closed, merged), the feature flag gets auto-tagged ("Issue Open", "MR Merged", etc.)

Key design decisions:

  • Project-level (not org-level like GitHub) — because GitLab tokens are typically project-scoped, and it's simpler: no org/project selection needed since you're already inside a project
  • Group/Project Access Tokens (not PATs) — enterprise-friendly, not tied to individual users, survives employee turnover
  • Self-managed support from day one — configurable instance URL per integration
  • No OAuth flow — direct token input, simpler setup, works for self-managed instances without registering an OAuth app

Technical Details for Reviewers

Backend (Django)

New Django app: api/integrations/gitlab/

Module What it does
models.py GitLabConfiguration — 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.py GitLab API v4 client — project listing, issue/MR search, comment posting, label management. All calls use PRIVATE-TOKEN header.
gitlab.py Webhook event handling, feature tagging logic, delegates to shared comment generation. call_gitlab_task for async dispatch.
tasks.py @register_task_handler for async comment posting. Parses GitLab URLs to extract project path + resource IID.
views.py GitLabConfigurationViewSet (nested under projects), function-based views for resource browsing (issues, MRs, projects, members), cleanup issue creation, webhook receiver.
serializers.py Model serialisers (separate create vs read to hide access_token in responses), dataclass serialisers for query params.
helpers.py Webhook validation — simple token comparison (not HMAC like GitHub).
permissions.py Project-level permission check via organisation membership.

New shared module: api/integrations/vcs/

Module What it does
comments.py Shared generate_body_comment() — markdown comment generation used by both GitHub and GitLab. Parameterised by unlinked_feature_text ("issue/PR" vs "issue/MR").
constants.py Shared message templates (feature table headers, row formats, etc.)
helpers.py Shared collect_feature_states_for_resource() — collects live feature states across all environments.

Modified files:

File Change
features/feature_external_resources/models.py Added GITLAB_ISSUE/GITLAB_MR to ResourceType. Refactored AFTER_SAVE/BEFORE_DELETE hooks to dispatch by type prefix (GitHub vs GitLab). Uses shared VCS helpers.
features/feature_external_resources/views.py Extended list (live metadata fetch from GitLab API) and create (URL validation, label application) for GitLab resource types.
features/models.py Added GitLab comment posting in Feature.create_github_comment and FeatureSegment.create_github_comment hooks.
features/serializers.py + features/versioning/serializers.py Added call_gitlab_task alongside call_github_task for flag update events.
projects/tags/models.py Added GITLAB to TagType enum.
projects/code_references/types.py Added GITLAB to VCSProvider enum.
projects/urls.py Registered GitLab viewset and resource browsing paths.
api/urls/v1.py Webhook URL: gitlab-webhook/<project_pk>/
integrations/github/github.py Refactored to delegate comment generation to shared integrations/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/N to /-/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 configuration
  • services/useGitlab.ts — RTK Query for project/issue/MR browsing
  • GitLabSetupPage.tsx — Setup modal with credentials form → repo selection → tagging toggle → webhook display with copy-to-clipboard
  • GitLabResourcesSelect.tsx — Issue/MR search component for the Links tab

Modified files:

  • IntegrationList.tsx — GitLab uses custom modal (same pattern as GitHub), uses integration flags instead of hardcoded key names for fetch skip logic
  • ExternalResourcesLinkTab.tsx — GitHub/GitLab toggle when both are configured
  • ExternalResourcesTable.tsx — Handles GitLab resource types
  • create-feature/index.js — Checks for GitLab integration to show Links tab
  • default-flags.ts — GitLab integration entry with form fields
  • utils.tsxgetIntegrationData() now merges defaults so new integrations appear before the remote flag is updated
  • CreateEditIntegrationModal.tsx — Populates default field values on init (fixes pre-existing bug where defaults showed but weren't submitted)

Tests

  • 287 tests covering GitLab + GitHub + external resources
  • test_unit_gitlab_client.py — 17 tests for API client functions
  • test_unit_gitlab_gitlab.py — 70 tests for webhook handling, tagging, comment generation, tasks, model hooks
  • test_unit_gitlab_views.py — 40 tests for views (config CRUD, webhook, resource browsing, cleanup issues)
  • Additional GitLab tests in test_unit_feature_external_resources_views.py
  • All existing GitHub tests continue to pass

Migrations

  • integrations/gitlab/0001_initial.py — Creates GitLabConfiguration table
  • feature_external_resources/0003_alter_featureexternalresource_type.py — Adds GitLab choices
  • projects/tags/0009_add_gitlab_tag_type.py — Adds GITLAB to TagType
  • projects/code_references/0003_alter_featureflagcodereferencesscan_vcs_provider.py — Adds GITLAB to VCSProvider

Quality improvements over initial PoC

These refactorings were done in a second pass to bring the code closer to production quality:

  1. Fixed regex bug[^/-] excluded hyphens from project paths, breaking URLs like my-group/my-project/-/issues/1. Replaced with [^/].
  2. Extracted shared VCS module (integrations/vcs/) — generate_body_comment was duplicated verbatim between GitHub and GitLab. Now shared, parameterised by PR/MR terminology.
  3. Extracted shared feature state helper — The "collect live feature states across environments" loop was duplicated. Now in integrations/vcs/helpers.py.
  4. Data-driven integration skip logicIntegrationList.tsx no longer hardcodes key !== 'github' && key !== 'gitlab'. Uses isExternalInstallation and isGitlabIntegration flags instead.

What I know needs more work

  • GitLab SVG icon — references /static/images/integrations/gitlab.svg which doesn't exist yet
  • Documentation — docs.flagsmith.com needs a GitLab integration page
  • Flagsmith on Flagsmith — the integration_data flag needs the gitlab entry added for production
  • Access token encryption — stored as plaintext (consistent with existing patterns, but should be encrypted for a credential)

How to test locally

# Backend
cd api
make docker-up
DATABASE_URL="postgresql://postgres:password@localhost:5432/flagsmith" .venv/bin/python manage.py migrate
DATABASE_URL="postgresql://postgres:password@localhost:5432/flagsmith" DJANGO_ALLOWED_HOSTS="*" .venv/bin/python manage.py runserver

# Frontend
cd frontend
npm install
ENV=local npm run dev

# Tests
cd api
DATABASE_URL="postgresql://postgres:password@localhost:5432/flagsmith" .venv/bin/pytest tests/unit/integrations/gitlab/ -v

You'll need a GitLab access token with api scope (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. 🍺

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Mar 27, 2026 11:48am
flagsmith-frontend-preview Ready Ready Preview, Comment Mar 27, 2026 11:48am
flagsmith-frontend-staging Ready Ready Preview, Comment Mar 27, 2026 11:48am

Request Review

@github-actions github-actions bot added front-end Issue related to the React Front End Dashboard api Issue related to the REST API feature New feature or request labels Mar 24, 2026
@asaphko asaphko force-pushed the feature/gitlab-integration branch from 99872ab to 2185d42 Compare March 24, 2026 13:32
@github-actions github-actions bot added feature New feature or request and removed feature New feature or request labels Mar 24, 2026
@asaphko asaphko force-pushed the feature/gitlab-integration branch from 207da17 to b27e98f Compare March 24, 2026 13:40
@github-actions github-actions bot added feature New feature or request and removed feature New feature or request labels Mar 24, 2026
@asaphko asaphko force-pushed the feature/gitlab-integration branch from 410a6dd to 77ec317 Compare March 24, 2026 14:36
@github-actions github-actions bot added feature New feature or request and removed feature New feature or request labels Mar 24, 2026
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 24, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.38%. Comparing base (3f71f16) to head (3399007).
⚠️ Report is 3 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@asaphko asaphko force-pushed the feature/gitlab-integration branch from b4874c1 to f3e5794 Compare March 24, 2026 15:35
@github-actions github-actions bot added feature New feature or request and removed feature New feature or request labels Mar 24, 2026
@asaphko asaphko force-pushed the feature/gitlab-integration branch from 40afbbc to 555bae5 Compare March 24, 2026 16:07
@github-actions github-actions bot added feature New feature or request and removed feature New feature or request labels Mar 24, 2026
@asaphko asaphko force-pushed the feature/gitlab-integration branch from 2f9a2dd to 181fda0 Compare March 24, 2026 16:15
@github-actions github-actions bot added feature New feature or request and removed feature New feature or request labels Mar 24, 2026
@asaphko asaphko force-pushed the feature/gitlab-integration branch from af2677d to 619885f Compare March 24, 2026 17:21
@github-actions github-actions bot added feature New feature or request and removed feature New feature or request labels Mar 24, 2026
@asaphko asaphko force-pushed the feature/gitlab-integration branch from 199d6d5 to 4cfe5eb Compare March 24, 2026 17:23
@github-actions github-actions bot added feature New feature or request and removed feature New feature or request labels Mar 24, 2026
@github-actions github-actions bot added feature New feature or request and removed feature New feature or request labels Mar 24, 2026
@asaphko asaphko force-pushed the feature/gitlab-integration branch from ec1e94f to d92a900 Compare March 24, 2026 18:04
@github-actions github-actions bot added feature New feature or request and removed feature New feature or request labels Mar 24, 2026
@asaphko asaphko force-pushed the feature/gitlab-integration branch from b1736ec to c0618a3 Compare March 24, 2026 18:41
@github-actions github-actions bot added feature New feature or request and removed feature New feature or request labels Mar 24, 2026
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>
@asaphko asaphko force-pushed the feature/gitlab-integration branch from 84fa3a0 to 09b6ad5 Compare March 24, 2026 18:54
@github-actions github-actions bot added feature New feature or request and removed feature New feature or request labels Mar 24, 2026
asaphko and others added 6 commits March 24, 2026 19:09
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
@github-actions github-actions bot added feature New feature or request and removed feature New feature or request labels Mar 24, 2026
@asaphko
Copy link
Copy Markdown
Contributor Author

asaphko commented Mar 24, 2026

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:

  • GitLab's new work_items URL format (not documented anywhere obvious)
  • The regex excluding hyphens from project paths (would have broken most real GitLab namespaces)
  • The FeatureExternalResource type lookup matching GITHUB_ISSUE instead of GITLAB_ISSUE because Object.keys().find() returns the first match
  • The OneToOneField conflicting with soft-delete (had to switch to ForeignKey + conditional unique constraint)

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:

  • The integration works end-to-end (tested live with real GitLab)
  • The shared integrations/vcs/ module is a genuine improvement — GitHub benefits too
  • Test coverage is comprehensive (287 tests, ~99.8% diff coverage)
  • The project-level design is the right call for GitLab

What I'd want you to scrutinise:

  • The FeatureExternalResource model is getting complex with the GitHub/GitLab dispatch — there might be a cleaner abstraction I couldn't see
  • The frontend GitLabSetupPage bypasses the standard CreateEditIntegrationModal — same pattern as GitHub, but worth discussing if there's a better way
  • The access_token is stored as plaintext — consistent with existing patterns but not ideal for a credential
  • My regex-fu was... educational. Please double-check the URL patterns

The session in numbers:

  • ~18 hours of back-and-forth
  • 48 files changed
  • Multiple CI fix cycles (migrations, mypy, ruff, lint, coverage — your CI is thorough and I respect that)
  • 1 PM who refused to stop until all checks were green

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
Copy link
Copy Markdown
Contributor

@Zaimwa9 Zaimwa9 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be preferred to use async await 🙏

Comment on lines +122 to +260
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>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's extract this in a single CreateGitlabIntegrationForm.tsx component too here so we can let this page container focused on data handling

Comment on lines +47 to +72
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


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

@khvn26 khvn26 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider making this a Literal.

Comment on lines +26 to +37
_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,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +47 to +57
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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +362 to +392
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",
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +521 to +523
assert UPDATED_FEATURE_TEXT % "my_flag" in result
assert "Production" in result
assert "Enabled" in result
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be one assert for the whole body comment text

Comment on lines +171 to +195
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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I feel like this logic should be abstracted out and, ideally, work for all VCS integrations.
  2. I believe we'll benefit from defining a type for this data (see my other comment about this).

)


def generate_data(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +16 to +88
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,
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Redundant DB lookups. GitLabConfiguration is 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's gitlab_project_id was available in step 1, it just wasn't passed through.

  2. URL parsing to recover data we already had. _post_to_resource regex-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.

  3. Needless serialisation round-trip. Step 2 builds GitLabData, converts to dict with asdict(). Step 3 deserialises back with from_dict(). The dataclass is just a transport bag.

  4. Two wrapper dataclasses. GitLabData and CallGitLabData where CallGitLabData.event_type is just GitLabData.type renamed, and the external resources are fetched inside the task anyway.

  5. Module placement. Per our api/README.md, which houses the backend architecture guidelines, mappers.py is for "data mapping logic unrelated to API requests and responses" — generate_data() and generate_feature_external_resources() are mapping domain objects into DTOs, which is exactly that. gitlab.py is doing service-layer work without being called services.py, and tasks.py contains business logic (URL resolution, comment generation orchestration) that should live in services.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 fetch GitLabConfiguration once, generate the comment body, and call post_comment_to_gitlab directly — no URL regex parsing, no intermediate dataclass reassembly.
  • Avoid intermediary DTOs that just shuttle data between functions in the same call. GitLabData gets built, serialised to dict, deserialised back, wrapped in CallGitLabData, then unpacked field-by-field in send_post_request. If the task payload is designed around what the task actually needs, most of this goes away.
  • Move mapping logic to mappers.pygenerate_data and generate_feature_external_resources convert domain objects to dicts, which is what mappers.py is for per our architecture guidelines.
  • Move orchestration logic to services.py — comment generation, URL resolution, and the branching in call_gitlab_app_webhook_for_feature_state are business logic. tasks.py should 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]:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we generate response types from Gitlab's OpenAPI schema? Consult our LaunchDarkly and Grafana client code for an example.

Comment on lines +28 to +32
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}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather the responses mocks validate expected headers — see https://github.com/getsentry/responses?tab=readme-ov-file#request-headers-validation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Issue related to the REST API feature New feature or request front-end Issue related to the React Front End Dashboard

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants