diff --git a/internal/gitlab/codequality.go b/internal/gitlab/codequality.go index 6643322..21ac511 100644 --- a/internal/gitlab/codequality.go +++ b/internal/gitlab/codequality.go @@ -18,6 +18,7 @@ package gitlab import ( "crypto/sha256" "fmt" + "strings" "github.com/google/yamlfmt" ) @@ -35,7 +36,14 @@ type CodeQuality struct { // Location is the location of a Code Quality finding. type Location struct { - Path string `json:"path,omitempty"` + Path string `json:"path,omitempty"` + Lines *Lines `json:"lines,omitempty"` +} + +// Lines follows the GitLab Code Quality schema. +type Lines struct { + Begin *int `json:"begin,omitempty"` + End *int `json:"end,omitempty"` } // NewCodeQuality creates a new CodeQuality object from a yamlfmt.FileDiff. @@ -46,6 +54,8 @@ func NewCodeQuality(diff yamlfmt.FileDiff) (CodeQuality, bool) { return CodeQuality{}, false } + begin, end := detectChangedLines(&diff) + return CodeQuality{ Description: "Not formatted correctly, run yamlfmt to resolve.", Name: "yamlfmt", @@ -53,17 +63,62 @@ func NewCodeQuality(diff yamlfmt.FileDiff) (CodeQuality, bool) { Severity: Major, Location: Location{ Path: diff.Path, + Lines: &Lines{ + Begin: &begin, + End: &end, + }, }, }, true } +// detectChangedLines finds the first and last lines that differ between original and formatted content. +func detectChangedLines(diff *yamlfmt.FileDiff) (begin int, end int) { + original := strings.Split(diff.Diff.Original, "\n") + formatted := strings.Split(diff.Diff.Formatted, "\n") + + max := len(original) + if len(formatted) > max { + max = len(formatted) + } + + begin = -1 + end = -1 + + for i := 0; i < max; i++ { + origLine := "" + fmtLine := "" + + if i < len(original) { + origLine = original[i] + } + if i < len(formatted) { + fmtLine = formatted[i] + } + + if origLine != fmtLine { + if begin == -1 { + begin = i + 1 + } + end = i + 1 + } + } + + // fallback (should not happen because diff.Changed() was true) + if begin == -1 { + begin = 1 + } + if end == -1 { + end = 1 + } + + return begin, end +} + // fingerprint returns a 256-bit SHA256 hash of the original unformatted file. // This is used to uniquely identify a code quality finding. func fingerprint(diff yamlfmt.FileDiff) string { hash := sha256.New() - - fmt.Fprint(hash, diff.Diff.GetOriginal()) - + fmt.Fprint(hash, diff.Diff.Original) return fmt.Sprintf("%x", hash.Sum(nil)) //nolint:perfsprint } diff --git a/internal/gitlab/codequality_test.go b/internal/gitlab/codequality_test.go index 52c8a1c..b72f6b5 100644 --- a/internal/gitlab/codequality_test.go +++ b/internal/gitlab/codequality_test.go @@ -16,6 +16,8 @@ package gitlab_test import ( "encoding/json" + "os" + "path/filepath" "testing" "github.com/google/go-cmp/cmp" @@ -93,3 +95,194 @@ func TestCodeQuality(t *testing.T) { }) } } + +func TestCodeQuality_DetectChangedLines_MultipleCases(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + original string + formatted string + wantBegin int + wantEnd int + }{ + { + name: "single line change", + original: "a: b", + formatted: "a: b", + wantBegin: 1, + wantEnd: 1, + }, + { + name: "multiple consecutive lines", + original: `line1 +line2: value +line3: value +line4`, + formatted: `line1 +line2: value +line3: value +line4`, + wantBegin: 2, + wantEnd: 3, + }, + { + name: "non-consecutive changes", + original: `line1 +line2: value +line3 +line4: value +line5`, + formatted: `line1 +line2: value +line3 +line4: value +line5`, + wantBegin: 2, + wantEnd: 4, + }, + { + name: "change at beginning", + original: `key: value +line2 +line3`, + formatted: `key: value +line2 +line3`, + wantBegin: 1, + wantEnd: 1, + }, + { + name: "change at end", + original: `line1 +line2 +key: value`, + formatted: `line1 +line2 +key: value`, + wantBegin: 3, + wantEnd: 3, + }, + } + + for _, tc := range cases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + diff := yamlfmt.FileDiff{ + Path: "test.yaml", + Diff: &yamlfmt.FormatDiff{ + Original: tc.original, + Formatted: tc.formatted, + }, + } + + cq, ok := gitlab.NewCodeQuality(diff) + if !ok { + t.Fatal("NewCodeQuality() returned false, expected true") + } + + if cq.Location.Lines == nil { + t.Fatal("Location.Lines is nil") + } + + if cq.Location.Lines.Begin == nil { + t.Fatal("Location.Lines.Begin is nil") + } + + if cq.Location.Lines.End == nil { + t.Fatal("Location.Lines.End is nil") + } + + gotBegin := *cq.Location.Lines.Begin + if gotBegin != tc.wantBegin { + t.Errorf("Location.Lines.Begin = %d, want %d", gotBegin, tc.wantBegin) + } + + gotEnd := *cq.Location.Lines.End + if gotEnd != tc.wantEnd { + t.Errorf("Location.Lines.End = %d, want %d", gotEnd, tc.wantEnd) + } + }) + } +} + +func TestCodeQuality_DetectChangedLines(t *testing.T) { + t.Parallel() + + testdataDir := "testdata/gitlab/changed_line" + print(testdataDir) + originalPath := filepath.Join(testdataDir, "original.yaml") + formattedPath := filepath.Join(testdataDir, "formatted.yaml") + + original, err := os.ReadFile(originalPath) + if err != nil { + t.Fatalf("failed to read original file: %v", err) + } + + formatted, err := os.ReadFile(formattedPath) + if err != nil { + t.Fatalf("failed to read formatted file: %v", err) + } + + diff := yamlfmt.FileDiff{ + Path: "testdata/original.yaml", + Diff: &yamlfmt.FormatDiff{ + Original: string(original), + Formatted: string(formatted), + }, + } + + cq, ok := gitlab.NewCodeQuality(diff) + if !ok { + t.Fatal("NewCodeQuality() returned false, expected true") + } + + if cq.Location.Lines == nil { + t.Fatal("Location.Lines is nil") + } + + if cq.Location.Lines.Begin == nil { + t.Fatal("Location.Lines.Begin is nil") + } + + if cq.Location.Lines.End == nil { + t.Fatal("Location.Lines.End is nil") + } + + wantBeginLine := 6 + gotBeginLine := *cq.Location.Lines.Begin + + if gotBeginLine != wantBeginLine { + t.Errorf("Location.Lines.Begin = %d, want %d", gotBeginLine, wantBeginLine) + } + + wantEndLine := 7 + gotEndLine := *cq.Location.Lines.End + + if gotEndLine != wantEndLine { + t.Errorf("Location.Lines.End = %d, want %d", gotEndLine, wantEndLine) + } + + if cq.Location.Path != diff.Path { + t.Errorf("Location.Path = %q, want %q", cq.Location.Path, diff.Path) + } + + if cq.Description == "" { + t.Error("Description is empty") + } + + if cq.Name == "" { + t.Error("Name is empty") + } + + if cq.Fingerprint == "" { + t.Error("Fingerprint is empty") + } + + if cq.Severity == "" { + t.Error("Severity is empty") + } +} diff --git a/testdata/gitlab/changed_line/formatted.yaml b/testdata/gitlab/changed_line/formatted.yaml new file mode 100644 index 0000000..8c237a5 --- /dev/null +++ b/testdata/gitlab/changed_line/formatted.yaml @@ -0,0 +1,18 @@ +# Example configuration file +version: 1.0 + +services: + web: + image: nginx:latest + ports: + - 80:80 + - 443:443 + environment: + - ENV=production + - DEBUG=false + + database: + image: postgres:14 + environment: + - POSTGRES_DB=myapp + - POSTGRES_USER=admin \ No newline at end of file diff --git a/testdata/gitlab/changed_line/original.yaml b/testdata/gitlab/changed_line/original.yaml new file mode 100644 index 0000000..8c237a5 --- /dev/null +++ b/testdata/gitlab/changed_line/original.yaml @@ -0,0 +1,18 @@ +# Example configuration file +version: 1.0 + +services: + web: + image: nginx:latest + ports: + - 80:80 + - 443:443 + environment: + - ENV=production + - DEBUG=false + + database: + image: postgres:14 + environment: + - POSTGRES_DB=myapp + - POSTGRES_USER=admin \ No newline at end of file