Skip to content

Commit 83a3cfb

Browse files
committed
wip: add code actions
1 parent 83ba883 commit 83a3cfb

File tree

5 files changed

+492
-1
lines changed

5 files changed

+492
-1
lines changed

infrastructure/oss/code_actions.go

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package oss
1818

1919
import (
2020
"fmt"
21+
"net/url"
2122
"os"
2223
"strings"
2324

@@ -209,3 +210,208 @@ func (i *ossIssue) getUpgradedPathParts() (string, string, error) {
209210
depVersion := rootDependencyUpgrade[len(rootDependencyUpgrade)-1]
210211
return depName, depVersion, nil
211212
}
213+
214+
// AddCodeActionsFromOssIssueData generates code actions from OssIssueData directly
215+
// This avoids the need to convert OssIssueData back to ossIssue for unified flow issues
216+
func AddCodeActionsFromOssIssueData(
217+
ossData snyk.OssIssueData,
218+
issueId string,
219+
learnService learn.Service,
220+
ep error_reporting.ErrorReporter,
221+
affectedFilePath types.FilePath,
222+
issueDepNode *ast.Node,
223+
) (actions []types.CodeAction) {
224+
c := config.CurrentConfig()
225+
if issueDepNode == nil {
226+
c.Logger().Debug().Str("issue", issueId).Msg("skipping adding code action, as issueDepNode is empty")
227+
return actions
228+
}
229+
230+
// let's see if we can offer a quickfix here
231+
// value has the version information, so if it's empty, we'll need to look at the parent
232+
var quickFixAction types.CodeAction
233+
if issueDepNode.Tree != nil && issueDepNode.Value == "" {
234+
fixNode := issueDepNode.LinkedParentDependencyNode
235+
if fixNode != nil {
236+
quickFixAction = AddQuickFixActionFromOssIssueData(ossData, types.FilePath(fixNode.Tree.Document), getRangeFromNode(fixNode), []byte(fixNode.Tree.Root.Value), true)
237+
}
238+
} else {
239+
quickFixAction = AddQuickFixActionFromOssIssueData(ossData, affectedFilePath, getRangeFromNode(issueDepNode), nil, false)
240+
}
241+
if quickFixAction != nil {
242+
actions = append(actions, quickFixAction)
243+
}
244+
245+
if c.IsSnykOpenBrowserActionEnabled() {
246+
title := fmt.Sprintf("Open description of '%s affecting package %s' in browser (Snyk)", ossData.Title, ossData.PackageName)
247+
issueURL := createIssueURLFromId(issueId)
248+
command := &types.CommandData{
249+
Title: title,
250+
CommandId: types.OpenBrowserCommand,
251+
Arguments: []any{issueURL.String()},
252+
}
253+
254+
action, err := snyk.NewCodeAction(title, nil, command)
255+
if err != nil {
256+
c.Logger().Err(err).Msgf("could not create code action %s", title)
257+
} else {
258+
actions = append(actions, action)
259+
}
260+
}
261+
262+
codeAction := AddSnykLearnActionFromOssIssueData(ossData, issueId, learnService, ep)
263+
if codeAction != nil {
264+
actions = append(actions, codeAction)
265+
}
266+
267+
return actions
268+
}
269+
270+
// AddSnykLearnActionFromOssIssueData creates a Snyk Learn code action from OssIssueData
271+
func AddSnykLearnActionFromOssIssueData(ossData snyk.OssIssueData, issueId string, learnService learn.Service, ep error_reporting.ErrorReporter) (action types.CodeAction) {
272+
if config.CurrentConfig().IsSnykLearnCodeActionsEnabled() {
273+
lesson, err := learnService.GetLesson(ossData.PackageManager, issueId, ossData.Identifiers.CWE, ossData.Identifiers.CVE, types.DependencyVulnerability)
274+
if err != nil {
275+
msg := "failed to get lesson"
276+
config.CurrentConfig().Logger().Err(err).Msg(msg)
277+
ep.CaptureError(errors.WithMessage(err, msg))
278+
return nil
279+
}
280+
281+
if lesson != nil && lesson.Url != "" {
282+
title := fmt.Sprintf("Learn more about %s (Snyk)", ossData.Title)
283+
action = &snyk.CodeAction{
284+
Title: title,
285+
OriginalTitle: title,
286+
Command: &types.CommandData{
287+
Title: title,
288+
CommandId: types.OpenBrowserCommand,
289+
Arguments: []any{lesson.Url},
290+
},
291+
}
292+
config.CurrentConfig().Logger().Debug().Str("method", "oss.AddSnykLearnActionFromOssIssueData").Msgf("Learn action: %v", action)
293+
}
294+
}
295+
return action
296+
}
297+
298+
// AddQuickFixActionFromOssIssueData creates a quick-fix code action from OssIssueData
299+
func AddQuickFixActionFromOssIssueData(ossData snyk.OssIssueData, affectedFilePath types.FilePath, issueRange types.Range, fileContent []byte, addFileNameToFixTitle bool) types.CodeAction {
300+
logger := config.CurrentConfig().Logger().With().Str("method", "oss.AddQuickFixActionFromOssIssueData").Logger()
301+
if !config.CurrentConfig().IsSnykOSSQuickFixCodeActionsEnabled() {
302+
return nil
303+
}
304+
logger.Debug().Msg("create deferred quickfix code action")
305+
filePathString := string(affectedFilePath)
306+
quickfixEdit := getQuickfixEditFromOssIssueData(ossData, affectedFilePath)
307+
if quickfixEdit == "" {
308+
return nil
309+
}
310+
upgradeMessage := "⚡️ Upgrade to " + quickfixEdit
311+
if addFileNameToFixTitle {
312+
upgradeMessage += " [ in file: " + filePathString + " ]"
313+
}
314+
autofixEditCallback := func() *types.WorkspaceEdit {
315+
edit := &types.WorkspaceEdit{}
316+
var err error
317+
if fileContent == nil {
318+
fileContent, err = os.ReadFile(filePathString)
319+
if err != nil {
320+
logger.Error().Err(err).Str("file", filePathString).Msg("could not open file")
321+
return edit
322+
}
323+
}
324+
325+
singleTextEdit := types.TextEdit{
326+
Range: issueRange,
327+
NewText: quickfixEdit,
328+
}
329+
edit.Changes = make(map[string][]types.TextEdit)
330+
edit.Changes[filePathString] = []types.TextEdit{singleTextEdit}
331+
return edit
332+
}
333+
334+
// our grouping key for oss quickfixes is the dependency name
335+
groupingKey, groupingValue, err := getUpgradedPathPartsFromOssIssueData(ossData)
336+
if err != nil {
337+
logger.Warn().Err(err).Msg("could not get the upgrade path, so cannot add quickfix.")
338+
return nil
339+
}
340+
341+
action, err := snyk.NewDeferredCodeAction(upgradeMessage, &autofixEditCallback, nil, types.Key(groupingKey), groupingValue)
342+
if err != nil {
343+
logger.Error().Msg("failed to create deferred quickfix code action")
344+
return nil
345+
}
346+
return &action
347+
}
348+
349+
// getQuickfixEditFromOssIssueData generates the quickfix edit text from OssIssueData
350+
func getQuickfixEditFromOssIssueData(ossData snyk.OssIssueData, affectedFilePath types.FilePath) string {
351+
logger := config.CurrentConfig().Logger().With().Str("method", "oss.getQuickfixEditFromOssIssueData").Logger()
352+
hasUpgradePath := len(ossData.UpgradePath) > 1
353+
if !hasUpgradePath {
354+
return ""
355+
}
356+
357+
// UpgradePath[0] is the upgrade for the package that was scanned
358+
// UpgradePath[1] is the upgrade for the root dependency
359+
depName, depVersion, err := getUpgradedPathPartsFromOssIssueData(ossData)
360+
if err != nil {
361+
logger.Warn().Err(err).Msg("could not get the upgrade path, so cannot add quickfix.")
362+
return ""
363+
}
364+
if len(ossData.UpgradePath) > 1 && len(ossData.From) > 1 {
365+
logger.Debug().Msgf("comparing %s with %s", ossData.UpgradePath[1], ossData.From[1])
366+
// from[1] contains the package that caused this issue
367+
normalizedCurrentVersion := strings.Split(ossData.From[1], "@")[1]
368+
if semver.Compare("v"+depVersion, "v"+normalizedCurrentVersion) == 0 {
369+
logger.Warn().Msg("proposed upgrade version is the same version as the current, not adding quickfix")
370+
return ""
371+
}
372+
}
373+
if ossData.PackageManager == "npm" || ossData.PackageManager == "yarn" || ossData.PackageManager == "yarn-workspace" {
374+
return fmt.Sprintf("\"%s\": \"%s\"", depName, depVersion)
375+
} else if ossData.PackageManager == "maven" {
376+
depNameSplit := strings.Split(depName, ":")
377+
depName = depNameSplit[len(depNameSplit)-1]
378+
// TODO: remove once https://snyksec.atlassian.net/browse/OSM-1775 is fixed
379+
if strings.Contains(string(affectedFilePath), "build.gradle") {
380+
return fmt.Sprintf("%s:%s", depName, depVersion)
381+
}
382+
return depVersion
383+
} else if ossData.PackageManager == "gradle" {
384+
depNameSplit := strings.Split(depName, ":")
385+
depName = depNameSplit[len(depNameSplit)-1]
386+
return fmt.Sprintf("%s:%s", depName, depVersion)
387+
}
388+
if ossData.PackageManager == "gomodules" {
389+
return fmt.Sprintf("v%s", depVersion)
390+
}
391+
392+
return ""
393+
}
394+
395+
// getUpgradedPathPartsFromOssIssueData extracts dependency name and version from OssIssueData
396+
func getUpgradedPathPartsFromOssIssueData(ossData snyk.OssIssueData) (string, string, error) {
397+
if len(ossData.UpgradePath) < 2 {
398+
return "", "", errors.New("upgrade path too short")
399+
}
400+
s, ok := ossData.UpgradePath[1].(string)
401+
if !ok {
402+
return "", "", errors.New("invalid upgrade path, could not cast to string")
403+
}
404+
rootDependencyUpgrade := strings.Split(s, "@")
405+
depName := strings.Join(rootDependencyUpgrade[:len(rootDependencyUpgrade)-1], "@")
406+
depVersion := rootDependencyUpgrade[len(rootDependencyUpgrade)-1]
407+
return depName, depVersion, nil
408+
}
409+
410+
// createIssueURLFromId creates an issue URL from an issue ID
411+
func createIssueURLFromId(issueId string) *url.URL {
412+
parse, err := url.Parse("https://snyk.io/vuln/" + issueId)
413+
if err != nil {
414+
config.CurrentConfig().Logger().Err(err).Msg("Unable to create issue link for issue:" + issueId)
415+
}
416+
return parse
417+
}

infrastructure/oss/ostest_scan.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,21 @@ package oss
1919
import (
2020
"context"
2121
"fmt"
22+
"os"
23+
"strings"
2224

2325
"github.com/snyk/go-application-framework/pkg/apiclients/testapi"
2426
"github.com/snyk/go-application-framework/pkg/configuration"
2527
"github.com/snyk/go-application-framework/pkg/envvars"
2628
"github.com/snyk/go-application-framework/pkg/workflow"
2729

30+
"github.com/snyk/snyk-ls/application/config"
31+
"github.com/snyk/snyk-ls/ast"
32+
"github.com/snyk/snyk-ls/domain/snyk"
33+
"github.com/snyk/snyk-ls/infrastructure/learn"
34+
ctx2 "github.com/snyk/snyk-ls/internal/context"
35+
"github.com/snyk/snyk-ls/internal/observability/error_reporting"
36+
"github.com/snyk/snyk-ls/internal/product"
2837
"github.com/snyk/snyk-ls/internal/types"
2938
)
3039

@@ -73,8 +82,96 @@ func processOsTestWorkFlowData(
7382
if err != nil {
7483
return nil, fmt.Errorf("couldn't convert test result to issues: %w", err)
7584
}
85+
// Enrich issues with quick-fix code actions and codelenses (unified path)
86+
issues = addUnifiedOssQuickFixesAndLenses(ctx, issues)
7687
}
7788
}
7889
}
7990
return issues, nil
8091
}
92+
93+
// addUnifiedOssQuickFixesAndLenses attaches OSS quick-fix code actions and related codelenses
94+
// to issues produced by the unified converter, reusing the legacy OSS machinery.
95+
func addUnifiedOssQuickFixesAndLenses(ctx context.Context, issues []types.Issue) []types.Issue {
96+
if len(issues) == 0 {
97+
return issues
98+
}
99+
cfg := config.CurrentConfig()
100+
if !cfg.IsSnykOSSQuickFixCodeActionsEnabled() {
101+
return issues
102+
}
103+
104+
// Resolve dependencies
105+
var learnService learn.Service
106+
var errorReporter error_reporting.ErrorReporter
107+
if deps, ok := ctx2.DependenciesFromContext(ctx); ok {
108+
if dep := deps[ctx2.DepLearnService]; dep != nil {
109+
if svc, ok := dep.(learn.Service); ok {
110+
learnService = svc
111+
}
112+
}
113+
if dep := deps[ctx2.DepErrorReporter]; dep != nil {
114+
if rep, ok := dep.(error_reporting.ErrorReporter); ok {
115+
errorReporter = rep
116+
}
117+
}
118+
}
119+
120+
enriched := make([]types.Issue, 0, len(issues))
121+
for _, it := range issues {
122+
issue := it // copy for modification
123+
124+
// Expect OSS additional data
125+
// Only handle issues that carry OSS additional data
126+
if issue.GetProduct() != product.ProductOpenSource {
127+
enriched = append(enriched, issue)
128+
continue
129+
}
130+
131+
// Type-assert to OssIssueData to work directly with it
132+
ossData, ok := it.GetAdditionalData().(snyk.OssIssueData)
133+
if !ok {
134+
enriched = append(enriched, issue)
135+
continue
136+
}
137+
138+
// compute dependency node for accurate range/code fix
139+
affected := issue.GetAffectedFilePath()
140+
depPath := ossData.From
141+
content, readErr := os.ReadFile(string(affected))
142+
if readErr != nil {
143+
cfg.Logger().Debug().Err(readErr).Str("file", string(affected)).Msg("cannot read file for quick-fix enrichment")
144+
}
145+
var node *ast.Node
146+
if readErr == nil {
147+
l := cfg.Logger().With().Logger()
148+
node = getDependencyNode(&l, affected, ossData.PackageManager, depPath, content)
149+
}
150+
151+
// add actions and derive lenses using OssIssueData
152+
actions := AddCodeActionsFromOssIssueData(ossData, issue.GetID(), learnService, errorReporter, affected, node)
153+
if len(actions) > 0 {
154+
issue.SetCodeActions(actions)
155+
var lenses []types.CommandData
156+
rangeFromNode := getRangeFromNode(node)
157+
for _, codeAction := range actions {
158+
if codeAction != nil && strings.Contains(codeAction.GetTitle(), "Upgrade to") {
159+
lenses = append(lenses, types.CommandData{
160+
Title: codeAction.GetTitle(),
161+
CommandId: types.CodeFixCommand,
162+
Arguments: []any{codeAction.GetUuid(), affected, rangeFromNode},
163+
GroupingKey: codeAction.GetGroupingKey(),
164+
GroupingType: codeAction.GetGroupingType(),
165+
GroupingValue: codeAction.GetGroupingValue(),
166+
})
167+
}
168+
}
169+
if len(lenses) > 0 {
170+
issue.SetCodelensCommands(lenses)
171+
}
172+
}
173+
174+
enriched = append(enriched, issue)
175+
}
176+
return enriched
177+
}

infrastructure/oss/testdata/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
{
2+
"name": "goof",
3+
"version": "1.0.0",
4+
"dependencies": {
5+
"lodash": "4.17.20"
6+
}
7+
}
18
{
29
"name": "goof",
310
"version": "1.0.1",

0 commit comments

Comments
 (0)