diff --git a/docs/output.md b/docs/output.md index b252563..08c04a3 100644 --- a/docs/output.md +++ b/docs/output.md @@ -31,4 +31,39 @@ Example: x.yaml: formatting difference found y.yaml: formatting difference found z.yaml: formatting difference found -``` \ No newline at end of file +``` + +## `gitlab` + +Generates a [GitLab Code Quality report](https://docs.gitlab.com/ee/ci/testing/code_quality.html#code-quality-report-format). + +Example: + +```json +[ + { + "description": "Not formatted correctly, run yamlfmt to resolve.", + "check_name": "yamlfmt", + "fingerprint": "c1dddeed9a8423b815cef59434fe3dea90d946016c8f71ecbd7eb46c528c0179", + "severity": "major", + "location": { + "path": ".gitlab-ci.yml" + } + }, +] +``` + +To use in a GitLab CI pipeline, first write the Code Quality report to a file, then upload the file as a Code Quality artifact. +Abbreviated example: + +```yaml +yamlfmt: + script: + - yamlfmt -dry -output_format gitlab . >yamlfmt-report + artifacts: + when: always + reports: + codequality: yamlfmt-report +``` + +With `-quiet`, the GitLab format will omit unnecessary whitespace to produce a more compact output. diff --git a/engine/output.go b/engine/output.go index 383f425..2d35e84 100644 --- a/engine/output.go +++ b/engine/output.go @@ -15,9 +15,13 @@ package engine import ( + "encoding/json" "fmt" + "sort" + "strings" "github.com/google/yamlfmt" + "github.com/google/yamlfmt/internal/gitlab" ) type EngineOutputFormat string @@ -25,6 +29,7 @@ type EngineOutputFormat string const ( EngineOutputDefault EngineOutputFormat = "default" EngineOutputSingeLine EngineOutputFormat = "line" + EngineOutputGitlab EngineOutputFormat = "gitlab" ) func getEngineOutput(t EngineOutputFormat, operation yamlfmt.Operation, files yamlfmt.FileDiffs, quiet bool) (fmt.Stringer, error) { @@ -33,6 +38,9 @@ func getEngineOutput(t EngineOutputFormat, operation yamlfmt.Operation, files ya return engineOutput{Operation: operation, Files: files, Quiet: quiet}, nil case EngineOutputSingeLine: return engineOutputSingleLine{Operation: operation, Files: files, Quiet: quiet}, nil + case EngineOutputGitlab: + return engineOutputGitlab{Operation: operation, Files: files, Compact: quiet}, nil + } return nil, fmt.Errorf("unknown output type: %s", t) } @@ -85,3 +93,46 @@ func (eosl engineOutputSingleLine) String() string { } return msg } + +type engineOutputGitlab struct { + Operation yamlfmt.Operation + Files yamlfmt.FileDiffs + Compact bool +} + +func (eo engineOutputGitlab) String() string { + var findings []gitlab.CodeQuality + + for _, file := range eo.Files { + if cq, ok := gitlab.NewCodeQuality(*file); ok { + findings = append(findings, cq) + } + } + + if len(findings) == 0 { + return "" + } + + sort.Sort(byPath(findings)) + + var b strings.Builder + enc := json.NewEncoder(&b) + + if !eo.Compact { + enc.SetIndent("", " ") + } + + if err := enc.Encode(findings); err != nil { + panic(err) + } + return b.String() +} + +// byPath is used to sort by Location.Path. +type byPath []gitlab.CodeQuality + +func (b byPath) Len() int { return len(b) } +func (b byPath) Less(i, j int) bool { return b[i].Location.Path < b[j].Location.Path } +func (b byPath) Swap(i, j int) { + b[i].Location.Path, b[j].Location.Path = b[j].Location.Path, b[i].Location.Path +} diff --git a/integrationtest/command/command_test.go b/integrationtest/command/command_test.go index e481c1b..46b162f 100644 --- a/integrationtest/command/command_test.go +++ b/integrationtest/command/command_test.go @@ -147,3 +147,11 @@ func TestStripDirectives(t *testing.T) { Update: *updateFlag, }.Run(t) } + +func TestGitLabOutput(t *testing.T) { + TestCase{ + Dir: "gitlab_output", + Command: yamlfmtWithArgs("-dry -output_format gitlab ."), + Update: *updateFlag, + }.Run(t) +} diff --git a/integrationtest/command/testdata/gitlab_output/after/correctly_formatted.yaml b/integrationtest/command/testdata/gitlab_output/after/correctly_formatted.yaml new file mode 100755 index 0000000..fe0a7f5 --- /dev/null +++ b/integrationtest/command/testdata/gitlab_output/after/correctly_formatted.yaml @@ -0,0 +1,3 @@ +# Test case "gitlab_output" + +needs: "no-op" diff --git a/integrationtest/command/testdata/gitlab_output/after/needs_format.yaml b/integrationtest/command/testdata/gitlab_output/after/needs_format.yaml new file mode 100755 index 0000000..d71aac7 --- /dev/null +++ b/integrationtest/command/testdata/gitlab_output/after/needs_format.yaml @@ -0,0 +1,4 @@ +# Test case "gitlab_output" + + +needs: "reformatting" diff --git a/integrationtest/command/testdata/gitlab_output/before/correctly_formatted.yaml b/integrationtest/command/testdata/gitlab_output/before/correctly_formatted.yaml new file mode 100644 index 0000000..fe0a7f5 --- /dev/null +++ b/integrationtest/command/testdata/gitlab_output/before/correctly_formatted.yaml @@ -0,0 +1,3 @@ +# Test case "gitlab_output" + +needs: "no-op" diff --git a/integrationtest/command/testdata/gitlab_output/before/needs_format.yaml b/integrationtest/command/testdata/gitlab_output/before/needs_format.yaml new file mode 100644 index 0000000..d71aac7 --- /dev/null +++ b/integrationtest/command/testdata/gitlab_output/before/needs_format.yaml @@ -0,0 +1,4 @@ +# Test case "gitlab_output" + + +needs: "reformatting" diff --git a/integrationtest/command/testdata/gitlab_output/stdout/stderr.txt b/integrationtest/command/testdata/gitlab_output/stdout/stderr.txt new file mode 100644 index 0000000..e69de29 diff --git a/integrationtest/command/testdata/gitlab_output/stdout/stdout.txt b/integrationtest/command/testdata/gitlab_output/stdout/stdout.txt new file mode 100644 index 0000000..d675074 --- /dev/null +++ b/integrationtest/command/testdata/gitlab_output/stdout/stdout.txt @@ -0,0 +1,11 @@ +[ + { + "description": "Not formatted correctly, run yamlfmt to resolve.", + "check_name": "yamlfmt", + "fingerprint": "e9b14e45ca01a9a72fda9b8356a9ddbbaf7fe8c47116790a51cd699ae1679353", + "severity": "major", + "location": { + "path": "needs_format.yaml" + } + } +] diff --git a/internal/gitlab/codequality.go b/internal/gitlab/codequality.go new file mode 100644 index 0000000..e03de2d --- /dev/null +++ b/internal/gitlab/codequality.go @@ -0,0 +1,79 @@ +// Copyright 2024 GitLab, Inc. +// +// 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 gitlab generates GitLab Code Quality reports. +package gitlab + +import ( + "crypto/sha256" + "fmt" + + "github.com/google/yamlfmt" +) + +// CodeQuality represents a single code quality finding. +// +// Documentation: https://docs.gitlab.com/ee/ci/testing/code_quality.html#code-quality-report-format +type CodeQuality struct { + Description string `json:"description,omitempty"` + Name string `json:"check_name,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Severity Severity `json:"severity,omitempty"` + Location Location `json:"location,omitempty"` +} + +// Location is the location of a Code Quality finding. +type Location struct { + Path string `json:"path,omitempty"` +} + +// NewCodeQuality creates a new CodeQuality object from a yamlfmt.FileDiff. +// +// If the file did not change, i.e. the diff is empty, an empty struct and false is returned. +func NewCodeQuality(diff yamlfmt.FileDiff) (CodeQuality, bool) { + if !diff.Diff.Changed() { + return CodeQuality{}, false + } + + return CodeQuality{ + Description: "Not formatted correctly, run yamlfmt to resolve.", + Name: "yamlfmt", + Fingerprint: fingerprint(diff), + Severity: Major, + Location: Location{ + Path: diff.Path, + }, + }, true +} + +// 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.Original) + + return fmt.Sprintf("%x", hash.Sum(nil)) //nolint:perfsprint +} + +// Severity is the severity of a code quality finding. +type Severity string + +const ( + Info Severity = "info" + Minor Severity = "minor" + Major Severity = "major" + Critical Severity = "critical" + Blocker Severity = "blocker" +) diff --git a/internal/gitlab/codequality_test.go b/internal/gitlab/codequality_test.go new file mode 100644 index 0000000..2ad0513 --- /dev/null +++ b/internal/gitlab/codequality_test.go @@ -0,0 +1,95 @@ +// Copyright 2024 GitLab, Inc. +// +// 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 gitlab_test + +import ( + "encoding/json" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/yamlfmt" + "github.com/google/yamlfmt/internal/gitlab" +) + +func TestCodeQuality(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + diff yamlfmt.FileDiff + wantOK bool + wantFingerprint string + }{ + { + name: "no diff", + diff: yamlfmt.FileDiff{ + Path: "testcase/no_diff.yaml", + Diff: &yamlfmt.FormatDiff{ + Original: "a: b", + Formatted: "a: b", + }, + }, + wantOK: false, + }, + { + name: "with diff", + diff: yamlfmt.FileDiff{ + Path: "testcase/with_diff.yaml", + Diff: &yamlfmt.FormatDiff{ + Original: "a: b", + Formatted: "a: b", + }, + }, + wantOK: true, + // SHA256 of diff.Diff.Original + wantFingerprint: "05088f1c296b4fd999a1efe48e4addd0f962a8569afbacc84c44630d47f09330", + }, + } + + for _, tc := range cases { + // copy tc to avoid capturing an aliased loop variable in a Goroutine. + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, gotOK := gitlab.NewCodeQuality(tc.diff) + if gotOK != tc.wantOK { + t.Fatalf("NewCodeQuality() = (%#v, %v), want (*, %v)", got, gotOK, tc.wantOK) + } + if !gotOK { + return + } + + if tc.wantFingerprint != "" && tc.wantFingerprint != got.Fingerprint { + t.Fatalf("NewCodeQuality().Fingerprint = %q, want %q", got.Fingerprint, tc.wantFingerprint) + } + + data, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + + var gotUnmarshal gitlab.CodeQuality + if err := json.Unmarshal(data, &gotUnmarshal); err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(got, gotUnmarshal); diff != "" { + t.Errorf("json.Marshal() and json.Unmarshal() mismatch (-got +want):\n%s", diff) + } + }) + } +}