From 3a7f9d9b2463108f89b630936c8c29c1cf1d8a7f Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Sun, 7 Jun 2026 12:22:16 +0000 Subject: [PATCH 1/2] feat(codeaction): add Snyk Remediation Agent code action provider [IDE-2052] Add RemediationProvider interface (domain/snyk/remediation) and wire it into CodeActionsService. When a provider is configured, GetCodeActions attaches a deferred "Fix with Snyk Remediation Agent" action to each fixable finding that has a stable FindingId. The action resolves lazily via codeAction/resolve so fix computation does not block the action list. Production DI passes nil, so no Remy actions appear until a real provider is wired. The nil guard suppresses action generation entirely, keeping the current IDE behaviour unchanged. The RemediationAgentQuickFix kind ("quickfix.snyk.remediationAgent") lets clients identify the action by kind rather than by localised title. --- application/codeaction/codeaction.go | 86 +++++++-- application/codeaction/codeaction_test.go | 6 +- application/codeaction/remediation_test.go | 213 +++++++++++++++++++++ application/di/init.go | 2 +- application/di/test_init.go | 2 +- domain/snyk/remediation/noop.go | 32 ++++ domain/snyk/remediation/noop_test.go | 34 ++++ domain/snyk/remediation/provider.go | 40 ++++ 8 files changed, 395 insertions(+), 20 deletions(-) create mode 100644 application/codeaction/remediation_test.go create mode 100644 domain/snyk/remediation/noop.go create mode 100644 domain/snyk/remediation/noop_test.go create mode 100644 domain/snyk/remediation/provider.go diff --git a/application/codeaction/codeaction.go b/application/codeaction/codeaction.go index 6f6415563..85ece2416 100644 --- a/application/codeaction/codeaction.go +++ b/application/codeaction/codeaction.go @@ -18,6 +18,7 @@ package codeaction import ( + "context" "errors" "fmt" "time" @@ -30,6 +31,7 @@ import ( "github.com/snyk/snyk-ls/application/config" "github.com/snyk/snyk-ls/domain/ide/converter" "github.com/snyk/snyk-ls/domain/snyk" + "github.com/snyk/snyk-ls/domain/snyk/remediation" "github.com/snyk/snyk-ls/infrastructure/featureflag" noti "github.com/snyk/snyk-ls/internal/notification" "github.com/snyk/snyk-ls/internal/types" @@ -47,12 +49,13 @@ type CodeActionsService struct { // actionsCache holds all the issues that were returns by the GetCodeActions method. // This is used to resolve the code actions later on in ResolveCodeAction. - actionsCache map[uuid.UUID]cachedAction - engine workflow.Engine - logger zerolog.Logger - fileWatcher dirtyFilesWatcher - notifier noti.Notifier - configResolver types.ConfigResolverInterface + actionsCache map[uuid.UUID]cachedAction + engine workflow.Engine + logger zerolog.Logger + fileWatcher dirtyFilesWatcher + notifier noti.Notifier + configResolver types.ConfigResolverInterface + remediationProvider remediation.RemediationProvider } type cachedAction struct { @@ -60,16 +63,17 @@ type cachedAction struct { action types.CodeAction } -func NewService(engine workflow.Engine, provider snyk.IssueProvider, fileWatcher dirtyFilesWatcher, notifier noti.Notifier, featureFlagService featureflag.Service, configResolver types.ConfigResolverInterface) *CodeActionsService { +func NewService(engine workflow.Engine, provider snyk.IssueProvider, fileWatcher dirtyFilesWatcher, notifier noti.Notifier, featureFlagService featureflag.Service, configResolver types.ConfigResolverInterface, remediationProvider remediation.RemediationProvider) *CodeActionsService { return &CodeActionsService{ - IssuesProvider: provider, - featureFlagService: featureFlagService, - actionsCache: make(map[uuid.UUID]cachedAction), - engine: engine, - logger: engine.GetLogger().With().Str("service", "CodeActionsService").Logger(), - fileWatcher: fileWatcher, - notifier: notifier, - configResolver: configResolver, + IssuesProvider: provider, + featureFlagService: featureFlagService, + actionsCache: make(map[uuid.UUID]cachedAction), + engine: engine, + logger: engine.GetLogger().With().Str("service", "CodeActionsService").Logger(), + fileWatcher: fileWatcher, + notifier: notifier, + configResolver: configResolver, + remediationProvider: remediationProvider, } } @@ -122,11 +126,63 @@ func (c *CodeActionsService) GetCodeActions(params types.CodeActionParams) []typ updatedIssues = filteredIssues } + remediationActions := c.remediationCodeActions(updatedIssues, path, folder.Path(), r) actions := converter.ToCodeActions(updatedIssues) + actions = append(actions, remediationActions...) c.logger.Debug().Msg(fmt.Sprint("Returning ", len(actions), " code actions")) return actions } +func (c *CodeActionsService) remediationCodeActions(issues []types.Issue, path types.FilePath, folderPath types.FilePath, r types.Range) []types.LSPCodeAction { + if c.remediationProvider == nil { + return nil + } + var actions []types.LSPCodeAction + for i := range issues { + issue := issues[i] + findingId := issue.GetFindingId() + if findingId == "" { + continue + } + additionalData := issue.GetAdditionalData() + if additionalData == nil || !additionalData.IsFixable() { + continue + } + // Capture loop variables for the closure. + issueFindingId := findingId + issueRange := r + issueProduct := issue.GetProduct() + provider := c.remediationProvider + deferredEdit := func() *types.WorkspaceEdit { + edit, err := provider.Remediate(context.Background(), remediation.RemediationRequest{ + FindingId: issueFindingId, + FilePath: path, + ContentRoot: folderPath, + Range: issueRange, + Product: issueProduct, + }) + if err != nil { + c.logger.Error().Err(err).Str("findingId", issueFindingId).Msg("remediation provider returned error") + } + return edit + } + action, err := snyk.NewDeferredCodeAction( + "Fix with Snyk Remediation Agent", + &deferredEdit, + nil, + "", + nil, + ) + if err == nil { + action.Kind = types.RemediationAgentQuickFix + lspAction := converter.ToCodeAction(issue, &action) + c.cacheCodeAction(&action, issue) + actions = append(actions, lspAction) + } + } + return actions +} + func (c *CodeActionsService) UpdateIssuesWithQuickFix(quickFixGroupables []types.Groupable, issues []types.Issue) []types.Issue { // we only allow one quickfix, so it needs to be grouped quickFix := c.getQuickFixAction(quickFixGroupables) diff --git a/application/codeaction/codeaction_test.go b/application/codeaction/codeaction_test.go index bdfa4ac31..34d373b10 100644 --- a/application/codeaction/codeaction_test.go +++ b/application/codeaction/codeaction_test.go @@ -108,7 +108,7 @@ func Test_GetCodeActions_NoIssues_ReturnsNil(t *testing.T) { var issues []types.Issue providerMock := mock_snyk.NewMockIssueProvider(ctrl) providerMock.EXPECT().IssuesForRange(gomock.Any(), gomock.Any()).Return(issues) - service := codeaction.NewService(engine, providerMock, watcher.NewFileWatcher(), notification.NewMockNotifier(), featureflag.NewFakeService(), types.NewConfigResolver(engine.GetLogger())) + service := codeaction.NewService(engine, providerMock, watcher.NewFileWatcher(), notification.NewMockNotifier(), featureflag.NewFakeService(), types.NewConfigResolver(engine.GetLogger()), nil) codeActionsParam := types.CodeActionParams{ TextDocument: sglsp.TextDocumentIdentifier{ URI: documentUriExample, @@ -281,7 +281,7 @@ func setupService(t *testing.T, engine workflow.Engine) *codeaction.CodeActionsS providerMock := mock_snyk.NewMockIssueProvider(gomock.NewController(t)) providerMock.EXPECT().IssuesForRange(gomock.Any(), gomock.Any()).Return([]types.Issue{}).AnyTimes() - service := codeaction.NewService(engine, providerMock, watcher.NewFileWatcher(), notification.NewMockNotifier(), featureflag.NewFakeService(), types.NewConfigResolver(engine.GetLogger())) + service := codeaction.NewService(engine, providerMock, watcher.NewFileWatcher(), notification.NewMockNotifier(), featureflag.NewFakeService(), types.NewConfigResolver(engine.GetLogger()), nil) return service } @@ -299,7 +299,7 @@ func setupWithSingleIssue(t *testing.T, engine workflow.Engine, issue types.Issu issues := []types.Issue{issue} providerMock.EXPECT().IssuesForRange(path, converter.FromRange(r)).Return(issues).AnyTimes() fileWatcher := watcher.NewFileWatcher() - service := codeaction.NewService(engine, providerMock, fileWatcher, notification.NewMockNotifier(), featureflag.NewFakeService(), types.NewConfigResolver(engine.GetLogger())) + service := codeaction.NewService(engine, providerMock, fileWatcher, notification.NewMockNotifier(), featureflag.NewFakeService(), types.NewConfigResolver(engine.GetLogger()), nil) codeActionsParam := types.CodeActionParams{ TextDocument: sglsp.TextDocumentIdentifier{ diff --git a/application/codeaction/remediation_test.go b/application/codeaction/remediation_test.go new file mode 100644 index 000000000..aab5a0275 --- /dev/null +++ b/application/codeaction/remediation_test.go @@ -0,0 +1,213 @@ +/* + * © 2026 Snyk Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package codeaction_test + +import ( + "context" + "testing" + + "github.com/golang/mock/gomock" + sglsp "github.com/sourcegraph/go-lsp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/snyk/snyk-ls/application/codeaction" + "github.com/snyk/snyk-ls/application/watcher" + "github.com/snyk/snyk-ls/domain/ide/converter" + "github.com/snyk/snyk-ls/domain/snyk" + "github.com/snyk/snyk-ls/domain/snyk/mock_snyk" + "github.com/snyk/snyk-ls/domain/snyk/remediation" + "github.com/snyk/snyk-ls/infrastructure/featureflag" + "github.com/snyk/snyk-ls/internal/notification" + "github.com/snyk/snyk-ls/internal/testutil" + "github.com/snyk/snyk-ls/internal/testutil/workspaceutil" + "github.com/snyk/snyk-ls/internal/types" + "github.com/snyk/snyk-ls/internal/uri" +) + +// fakeRemediationProvider is a test double that returns a pre-configured edit. +type fakeRemediationProvider struct { + edit *types.WorkspaceEdit + err error +} + +func (f *fakeRemediationProvider) Remediate(_ context.Context, _ remediation.RemediationRequest) (*types.WorkspaceEdit, error) { + return f.edit, f.err +} + +// buildFixableIssue constructs a Code issue with HasAIFix=true and a FindingId set. +func buildFixableIssue(findingId string) *snyk.Issue { + return &snyk.Issue{ + FindingId: findingId, + AdditionalData: snyk.CodeIssueData{ + HasAIFix: true, + }, + } +} + +func setupWithIssueAndProvider( + t *testing.T, + issue types.Issue, + provider remediation.RemediationProvider, +) (*codeaction.CodeActionsService, types.CodeActionParams) { + t.Helper() + engine := testutil.UnitTest(t) + r := exampleRange + uriPath := documentUriExample + path := uri.PathFromUri(uriPath) + + _, _ = workspaceutil.SetupWorkspace(t, engine, types.FilePath("/path/to")) + + ctrl := gomock.NewController(t) + providerMock := mock_snyk.NewMockIssueProvider(ctrl) + var issues []types.Issue + if issue != nil { + issues = []types.Issue{issue} + } + providerMock.EXPECT().IssuesForRange(path, converter.FromRange(r)).Return(issues).AnyTimes() + + service := codeaction.NewService( + engine, + providerMock, + watcher.NewFileWatcher(), + notification.NewMockNotifier(), + featureflag.NewFakeService(), + types.NewConfigResolver(engine.GetLogger()), + provider, + ) + + params := types.CodeActionParams{ + TextDocument: sglsp.TextDocumentIdentifier{URI: uriPath}, + Range: r, + Context: types.CodeActionContext{}, + } + return service, params +} + +func TestGetCodeActions_RemediationAgent_OfferedForFixableIssue(t *testing.T) { + mockEdit := &types.WorkspaceEdit{ + Changes: map[string][]types.TextEdit{ + "file:///path/to/file": { + { + Range: types.Range{Start: types.Position{Line: 1}, End: types.Position{Line: 2}}, + NewText: "fixed", + }, + }, + }, + } + fake := &fakeRemediationProvider{edit: mockEdit} + issue := buildFixableIssue("finding-abc") + + service, params := setupWithIssueAndProvider(t, issue, fake) + + actions := service.GetCodeActions(params) + + // At least one action must have RemediationAgentQuickFix kind and a non-nil UUID (deferred). + found := false + for _, a := range actions { + if a.Kind == types.RemediationAgentQuickFix { + found = true + assert.NotNil(t, a.Data, "deferred action must carry a UUID in Data") + assert.Nil(t, a.Edit, "edit must be nil at list time (deferred)") + } + } + assert.True(t, found, "expected at least one RemediationAgentQuickFix action") +} + +func TestGetCodeActions_RemediationAgent_NilProvider_NoRemediationAction(t *testing.T) { + issue := buildFixableIssue("finding-xyz") + service, params := setupWithIssueAndProvider(t, issue, nil) + + actions := service.GetCodeActions(params) + + for _, a := range actions { + assert.NotEqual(t, types.RemediationAgentQuickFix, a.Kind, + "nil provider must not produce RemediationAgentQuickFix actions") + } +} + +func TestGetCodeActions_RemediationAgent_EmptyFindingId_NoAction(t *testing.T) { + fake := &fakeRemediationProvider{edit: &types.WorkspaceEdit{}} + // FindingId is empty — provider set, but no action should be generated. + issue := buildFixableIssue("") + + service, params := setupWithIssueAndProvider(t, issue, fake) + + actions := service.GetCodeActions(params) + + for _, a := range actions { + assert.NotEqual(t, types.RemediationAgentQuickFix, a.Kind, + "empty FindingId must not produce RemediationAgentQuickFix actions") + } +} + +func TestResolveCodeAction_RemediationAgent_InvokesProvider(t *testing.T) { + mockEdit := &types.WorkspaceEdit{ + Changes: map[string][]types.TextEdit{ + "file:///path/to/file": { + {NewText: "provider-fixed"}, + }, + }, + } + fake := &fakeRemediationProvider{edit: mockEdit} + issue := buildFixableIssue("finding-resolve") + + service, params := setupWithIssueAndProvider(t, issue, fake) + + actions := service.GetCodeActions(params) + + // Find the Remy action. + var remyAction *types.LSPCodeAction + for i := range actions { + if actions[i].Kind == types.RemediationAgentQuickFix { + remyAction = &actions[i] + break + } + } + require.NotNil(t, remyAction, "expected RemediationAgentQuickFix action") + + resolved, err := service.ResolveCodeAction(*remyAction) + require.NoError(t, err) + require.NotNil(t, resolved.Edit, "resolved action must have an edit") + // The converter transforms the key via uri.PathToUri; just verify the edit is populated. + assert.Len(t, resolved.Edit.Changes, 1, "resolved edit must carry the provider's changes") +} + +func TestGetCodeActions_RemediationAgent_DoesNotMutateIssueCodeActions(t *testing.T) { + fake := &fakeRemediationProvider{edit: &types.WorkspaceEdit{}} + issue := buildFixableIssue("finding-mutate") + + service, params := setupWithIssueAndProvider(t, issue, fake) + + // Record the number of code actions on the issue before calling GetCodeActions. + beforeCount := len(issue.GetCodeActions()) + + actions := service.GetCodeActions(params) + + // The issue's own CodeActions slice must be unchanged. + assert.Equal(t, beforeCount, len(issue.GetCodeActions()), + "GetCodeActions must not mutate issue.CodeActions") + + // The returned action list must still contain a RemediationAgentQuickFix. + found := false + for _, a := range actions { + if a.Kind == types.RemediationAgentQuickFix { + found = true + } + } + assert.True(t, found, "expected RemediationAgentQuickFix in returned actions") +} diff --git a/application/di/init.go b/application/di/init.go index 2dad88dbb..6ed39ca62 100644 --- a/application/di/init.go +++ b/application/di/init.go @@ -227,7 +227,7 @@ func initApplication(conf configuration.Configuration, engine workflow.Engine, l w := workspace.New(conf, logger, instrumentor, scanner, hoverService, scanNotifier, notifier, scanPersister, scanStateAggregator, featureFlagService, configResolver, engine) // don't use getters or it'll deadlock config.SetWorkspace(conf, w) fileWatcher = watcher.NewFileWatcher() - codeActionService = codeaction.NewService(engine, w, fileWatcher, notifier, featureFlagService, configResolver) + codeActionService = codeaction.NewService(engine, w, fileWatcher, notifier, featureFlagService, configResolver, nil) command.SetService(command.NewService(engine, logger, authenticationService, featureFlagService, notifier, learnService, w, snykCodeScanner, snykCli, ldxSyncService, configResolver, scanStateAggregator.StateSnapshot)) } diff --git a/application/di/test_init.go b/application/di/test_init.go index a10106274..cc7a815ca 100644 --- a/application/di/test_init.go +++ b/application/di/test_init.go @@ -155,6 +155,6 @@ func TestInit(t *testing.T, engine workflow.Engine, tokenService types.TokenServ w := workspace.New(gafConfiguration, logger, instrumentor, scanner, hoverService, scanNotifier, notifier, scanPersister, scanStateAggregator, featureFlagService, configResolver, engine) config.SetWorkspace(gafConfiguration, w) fileWatcher = watcher.NewFileWatcher() - codeActionService = codeaction.NewService(engine, w, fileWatcher, notifier, featureFlagService, configResolver) + codeActionService = codeaction.NewService(engine, w, fileWatcher, notifier, featureFlagService, configResolver, nil) return currentDependencies() } diff --git a/domain/snyk/remediation/noop.go b/domain/snyk/remediation/noop.go new file mode 100644 index 000000000..f7fcc4ee5 --- /dev/null +++ b/domain/snyk/remediation/noop.go @@ -0,0 +1,32 @@ +/* + * © 2026 Snyk Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package remediation + +import ( + "context" + + "github.com/snyk/snyk-ls/internal/types" +) + +// NoopProvider always returns no fix. Replace with a real implementation when available. +type NoopProvider struct{} + +var _ RemediationProvider = (*NoopProvider)(nil) + +func (n *NoopProvider) Remediate(_ context.Context, _ RemediationRequest) (*types.WorkspaceEdit, error) { + return nil, nil +} diff --git a/domain/snyk/remediation/noop_test.go b/domain/snyk/remediation/noop_test.go new file mode 100644 index 000000000..3349fa737 --- /dev/null +++ b/domain/snyk/remediation/noop_test.go @@ -0,0 +1,34 @@ +/* + * © 2026 Snyk Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package remediation_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/snyk/snyk-ls/domain/snyk/remediation" +) + +func TestNoopProvider_ReturnsNil(t *testing.T) { + provider := &remediation.NoopProvider{} + edit, err := provider.Remediate(context.Background(), remediation.RemediationRequest{}) + require.NoError(t, err) + assert.Nil(t, edit) +} diff --git a/domain/snyk/remediation/provider.go b/domain/snyk/remediation/provider.go new file mode 100644 index 000000000..305e2f144 --- /dev/null +++ b/domain/snyk/remediation/provider.go @@ -0,0 +1,40 @@ +/* + * © 2026 Snyk Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package remediation defines the interface for autonomous finding remediation. +package remediation + +import ( + "context" + + "github.com/snyk/snyk-ls/internal/product" + "github.com/snyk/snyk-ls/internal/types" +) + +// RemediationRequest describes the finding for which a fix is requested. +type RemediationRequest struct { + FindingId string + FilePath types.FilePath + ContentRoot types.FilePath + Range types.Range + Product product.Product +} + +// RemediationProvider computes an autonomous fix for a single finding. +// Returns nil when no fix can be computed; callers treat nil as "no fix available". +type RemediationProvider interface { + Remediate(ctx context.Context, req RemediationRequest) (*types.WorkspaceEdit, error) +} From 0ea7ea91f2a62c93c7b81391d36c9d9335e39271 Mon Sep 17 00:00:00 2001 From: Bastian Doetsch Date: Tue, 9 Jun 2026 05:18:12 +0000 Subject: [PATCH 2/2] feat(codeaction): extend remy to IaC, SCA, Code findings [IDE-2052] Remy remediation is now offered for Snyk Code (HasAIFix), Snyk Open Source (upgradable), and Snyk IaC findings. Secrets are excluded because automated secret remediation requires credential rotation outside remy's scope. IaC bypasses the IsFixable guard (which always returns false for IaC) so remy can be offered. Code and OSS retain the IsFixable check to ensure a fixability signal exists before invoking the agent. Add tests for Secrets (excluded), ProductUnknown (excluded), and IaC (offered) product filter branches. --- application/codeaction/codeaction.go | 8 ++- application/codeaction/remediation_test.go | 59 ++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/application/codeaction/codeaction.go b/application/codeaction/codeaction.go index 85ece2416..da1d0c151 100644 --- a/application/codeaction/codeaction.go +++ b/application/codeaction/codeaction.go @@ -34,6 +34,7 @@ import ( "github.com/snyk/snyk-ls/domain/snyk/remediation" "github.com/snyk/snyk-ls/infrastructure/featureflag" noti "github.com/snyk/snyk-ls/internal/notification" + "github.com/snyk/snyk-ls/internal/product" "github.com/snyk/snyk-ls/internal/types" "github.com/snyk/snyk-ls/internal/uri" ) @@ -144,14 +145,17 @@ func (c *CodeActionsService) remediationCodeActions(issues []types.Issue, path t if findingId == "" { continue } + issueProduct := issue.GetProduct() + if issueProduct == product.ProductSecrets || issueProduct == product.ProductUnknown { + continue + } additionalData := issue.GetAdditionalData() - if additionalData == nil || !additionalData.IsFixable() { + if issueProduct != product.ProductInfrastructureAsCode && (additionalData == nil || !additionalData.IsFixable()) { continue } // Capture loop variables for the closure. issueFindingId := findingId issueRange := r - issueProduct := issue.GetProduct() provider := c.remediationProvider deferredEdit := func() *types.WorkspaceEdit { edit, err := provider.Remediate(context.Background(), remediation.RemediationRequest{ diff --git a/application/codeaction/remediation_test.go b/application/codeaction/remediation_test.go index aab5a0275..f789bfa49 100644 --- a/application/codeaction/remediation_test.go +++ b/application/codeaction/remediation_test.go @@ -33,6 +33,7 @@ import ( "github.com/snyk/snyk-ls/domain/snyk/remediation" "github.com/snyk/snyk-ls/infrastructure/featureflag" "github.com/snyk/snyk-ls/internal/notification" + "github.com/snyk/snyk-ls/internal/product" "github.com/snyk/snyk-ls/internal/testutil" "github.com/snyk/snyk-ls/internal/testutil/workspaceutil" "github.com/snyk/snyk-ls/internal/types" @@ -53,6 +54,7 @@ func (f *fakeRemediationProvider) Remediate(_ context.Context, _ remediation.Rem func buildFixableIssue(findingId string) *snyk.Issue { return &snyk.Issue{ FindingId: findingId, + Product: product.ProductCode, AdditionalData: snyk.CodeIssueData{ HasAIFix: true, }, @@ -211,3 +213,60 @@ func TestGetCodeActions_RemediationAgent_DoesNotMutateIssueCodeActions(t *testin } assert.True(t, found, "expected RemediationAgentQuickFix in returned actions") } + +func TestGetCodeActions_RemediationAgent_SecretsIssue_NoAction(t *testing.T) { + fake := &fakeRemediationProvider{edit: &types.WorkspaceEdit{}} + issue := &snyk.Issue{ + FindingId: "finding-secret", + Product: product.ProductSecrets, + AdditionalData: snyk.SecretsIssueData{}, + } + + service, params := setupWithIssueAndProvider(t, issue, fake) + + actions := service.GetCodeActions(params) + + for _, a := range actions { + assert.NotEqual(t, types.RemediationAgentQuickFix, a.Kind, + "Secrets product must not produce RemediationAgentQuickFix actions") + } +} + +func TestGetCodeActions_RemediationAgent_UnknownProduct_NoAction(t *testing.T) { + fake := &fakeRemediationProvider{edit: &types.WorkspaceEdit{}} + issue := &snyk.Issue{ + FindingId: "finding-unknown", + Product: product.ProductUnknown, + AdditionalData: snyk.CodeIssueData{HasAIFix: true}, + } + + service, params := setupWithIssueAndProvider(t, issue, fake) + + actions := service.GetCodeActions(params) + + for _, a := range actions { + assert.NotEqual(t, types.RemediationAgentQuickFix, a.Kind, + "unknown product must not produce RemediationAgentQuickFix actions") + } +} + +func TestGetCodeActions_RemediationAgent_IaCIssue_WithFindingId_OfferedAction(t *testing.T) { + fake := &fakeRemediationProvider{edit: &types.WorkspaceEdit{}} + issue := &snyk.Issue{ + FindingId: "finding-iac", + Product: product.ProductInfrastructureAsCode, + AdditionalData: snyk.IaCIssueData{}, + } + + service, params := setupWithIssueAndProvider(t, issue, fake) + + actions := service.GetCodeActions(params) + + found := false + for _, a := range actions { + if a.Kind == types.RemediationAgentQuickFix { + found = true + } + } + assert.True(t, found, "IaC issue with FindingId must produce RemediationAgentQuickFix action") +}