Hide deprecated Slack integration when project never used it

What does this MR do and why?

This hides the deprecated "Slack notifications" integration (Integrations::Slack) from a project's Settings > Integrations list, so users are steered toward its replacement, the GitLab for Slack app.

Project#find_or_initialize_integrations now subtracts a new Project#hidden_integrations list when building the list shown on the integrations page. hidden_integrations returns ['slack'] only when all of these are true:

  1. The GitLab for Slack app is reachable for this project. This is checked with Integration.available_integration_names.include?(Integrations::GitlabSlackApplication.to_param), which covers both the slack_app_enabled application setting and the EE allowed_integrations allowlist in one check.
  2. The project has no Integrations::Slack record of its own.
  3. No instance-level Integrations::Slack record exists either.

Design history: why this isn't a feature flag, and isn't disabled_integrations

An earlier revision of this MR added slack to Project#disabled_integrations, guarded by a feature flag. That approach made find_or_initialize_integration return nil for Slack, which broke several things: the settings edit/update/test actions returned 404, integration hook log pages returned 404, GET and DELETE /projects/:id/integrations/slack returned 404, and PUT /projects/:id/integrations/slack returned 400.

That is a backward-incompatible change to a stable REST v4 endpoint. Per doc/api/rest/_index.md, the REST API follows semantic versioning and backward-incompatible changes require a major version change. The only exemptions from the deprecation process are experimental or beta elements, and fields behind a feature flag that is disabled by default.

So the approach changed to filtering only where the list is built for display. find_or_initialize_integration is untouched, so the edit page, the hook log pages, and every REST endpoint behave exactly as before. Because the API behaviour no longer changes, the feature flag is no longer needed and has been removed.

Integration.disabled_integration_names was not reused because the condition needs per-project state — whether this project already has its own Slack record — and that class-level list is instance-wide, so it cannot express it.

Scope

Project-level only. Group-level and instance-level integration settings pages still list Slack notifications. That matches the scope of the issue and is deliberate, not an oversight.

Group-level Slack integrations, and a known window

A group-level Slack integration reaches its projects as real Integrations::Slack rows, created by Projects::CreateService when a project is created, or backfilled by PropagateIntegrationProjectWorker for existing projects. Once that row exists, condition 2 keeps the integration visible for that project.

Until propagation runs, a project in a group that has just enabled Slack notifications sees it hidden. This window is accepted rather than closed: closing it means calling Integration.closest_group_integration on every integrations page load, adding a database query to cover a window that self-heals. A spec pins the current behaviour rather than endorsing it.

Because only the list is filtered, /-/settings/integrations/slack/edit still loads for a project where the integration is hidden. That is intentional — bookmarks and API-driven setup keep working (this is demonstrated in the second video.)

Which option from the issue this implements

The issue lists four candidate criteria joined by "or", ending with "Some other way...open to suggestions!". This implements the third: never set up in the current project. One consequence worth flagging is that a project whose sibling projects actively use Slack notifications still has it hidden if that project has no record of its own.

@katiemacoy confirmed on the issue (https://gitlab.com/gitlab-org/gitlab/-/issues/582684) that the intent is to stop users setting up the deprecated Slack notifications integration and direct them to the GitLab for Slack app instead.

References

Screenshots or screen recordings

Before After
Screenshot 2026-09-11 at 4.18.02 PM.png Screenshot 2026-09-11 at 2.56.37 PM.png

How to set up and validate locally

1. Baseline: nothing changes while the replacement is unavailable

ApplicationSetting.current.update!(slack_app_enabled: false)
Project.find(project.id).find_or_initialize_integrations.map(&:to_param).grep(/slack/)
# => ["slack"]

Open Settings > Integrations. Slack notifications is listed, GitLab for Slack app is not. This is master's behaviour, unchanged.

2. The hide: replacement available, project never used the deprecated one

ApplicationSetting.current.update!(slack_app_enabled: true)
Project.find(project.id).find_or_initialize_integrations.map(&:to_param).grep(/slack/)
# => ["gitlab_slack_application"]

Reload Settings > Integrations. Slack notifications is gone, GitLab for Slack app is present.

This is the only state this MR changes.

3. Project-level record: the deprecated integration comes back

Integrations::Slack.create!(project: project, active: false, webhook: 'https://example.com')
Project.find(project.id).find_or_initialize_integrations.map(&:to_param).grep(/slack/)
# => ["gitlab_slack_application", "slack"]

Reload the page. Both are listed. Note that an inactive record is enough — the check is for a record, not for an active one, so a project that configured and then disabled Slack notifications keeps seeing it.

Clean up before the next step:

Integrations::Slack.where(project: project).delete_all

4. Instance-level record: the deprecated integration comes back

Integrations::Slack.create!(instance: true, active: false, webhook: 'https://example.com')
Project.find(project.id).find_or_initialize_integrations.map(&:to_param).grep(/slack/)
# => ["gitlab_slack_application", "slack"]

Confirm which condition actually fired. Creating an instance-level integration can enqueue propagation, which would create a project-level row and make this look like a pass for the wrong reason:

Integrations::Slack.where(project: project).exists?
# => false, otherwise you are re-testing step 3

Clean up:

Integrations::Slack.where(instance: true).delete_all

5. The replacement is never hidden

Return to the step 2 state. Only the deprecated integration is hidden; gitlab_slack_application stays in the list. Hiding the replacement alongside the deprecated one is the failure mode that would matter most, so confirm it explicitly:

Project.find(project.id).hidden_integrations
# => ["slack"]

Project.find(project.id).find_or_initialize_integrations.map(&:to_param).grep(/slack/)
# => ["gitlab_slack_application"]

Note that the hiding is done by hidden_integrations, not by disabled_integrations. The deprecated integration is deliberately kept out of disabled_integrations, because putting it there is what broke the API endpoints in the earlier revision:

Project.find(project.id).disabled_integrations
# On EE this also includes "github" and the google_cloud_platform_* names,
# depending on licence and SaaS settings. What matters is the absence:
# it must not contain "slack", and it must not contain "gitlab_slack_application".

6. Non-regression: everything outside the list behaves as it does on master

Return to the step 2 state (app enabled, no Slack records anywhere). Because only the displayed list is filtered, find_or_initialize_integration still resolves slack normally, so the edit page, the hook log pages, and every REST endpoint respond exactly as they do on master. This is the part the earlier revision broke.

In the browser, both of these load normally rather than returning 404:

  • /<namespace>/<project>/-/settings/integrations/slack/edit
  • /<namespace>/<project>/-/settings/integrations/slack/hook_logs

Over the API, on both the /integrations and legacy /services mounts:

# 404 "Integration Not Found" — because this project has no Slack record yet,
# which is exactly what master returns for any unconfigured integration.
# Under the earlier revision this 404'd even for projects that DID have a record.
curl --header "PRIVATE-TOKEN: $TOKEN" \
  "http://gdk.test:3000/api/v4/projects/$PROJECT_ID/integrations/slack"

curl --header "PRIVATE-TOKEN: $TOKEN" \
  "http://gdk.test:3000/api/v4/projects/$PROJECT_ID/services/slack"

# 404, same reason: nothing to disable until a record exists. Matches master.
curl --request DELETE --header "PRIVATE-TOKEN: $TOKEN" \
  "http://gdk.test:3000/api/v4/projects/$PROJECT_ID/integrations/slack"

# 200, and the integration is created. The earlier revision returned
# 400 "Integration not available" here.
curl --request PUT --header "PRIVATE-TOKEN: $TOKEN" \
  --data "webhook=https://hooks.slack.com/services/mock" \
  "http://gdk.test:3000/api/v4/projects/$PROJECT_ID/integrations/slack"

Run the PUT last. It creates a project-level Slack record, which satisfies condition 2 and makes the integration visible in the list again, so every check above it changes meaning once it has run. Re-run GET and DELETE after it to see the configured-project responses (200 and 204), then clean up:

Integrations::Slack.where(project: project).delete_all

The list endpoint is unaffected for the same reason as everything else — only the settings list is filtered, and it reads project.integrations.active directly rather than going through find_or_initialize_integration. It returns active integrations only, so a hidden-but-unconfigured Slack would not appear there on master either:

# 200
curl --header "PRIVATE-TOKEN: $TOKEN" \
  "http://gdk.test:3000/api/v4/projects/$PROJECT_ID/integrations"

This is pinned by specs rather than left to manual checking: spec/controllers/projects/settings/integrations_controller_spec.rb ("still renders the edit page, so existing links and the API keep working") and spec/models/project_spec.rb ("still resolves the deprecated Slack integration while it is hidden from the list").

Summary of expected states

slack_app_enabled Slack notifications record exists Slack notifications in list
off no shown
off yes (project or instance) shown
on no hidden
on yes, project-level shown
on yes, instance-level shown

Reset

ApplicationSetting.current.update!(slack_app_enabled: false)
Integrations::Slack.where(project: project).delete_all
Integrations::Slack.where(instance: true).delete_all

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

🤖 Generated with Claude Code

Edited by Buck O'Leary

Merge request reports

Loading
Loading