Repository: google/yamlfmt
Pull request #220 (2024-11-20 10:33:03)

output: Add the `gitlab` output.
This adds a new output format, `gitlab`, which produces [Code Quality reports](https://docs.gitlab.com/ee/ci/testing/code_quality.html#code-quality-report-format) for GitLab. This allows GitLab CI jobs to annotate which files need formatting. Without this, a CI job can only fail, leaving the user to sieve through the logs to find out which files they need to format.

Example output:

```json
[
  {
    "description": "Not formatted correctly, run yamlfmt to resolve.",
    "check_name": "yamlfmt",
    "fingerprint": "c1dddeed9a8423b815cef59434fe3dea90d946016c8f71ecbd7eb46c528c0179",
    "severity": "major",
    "location": {
      "path": ".golangci.yaml"
    }
  }
]
```

google-cla[bot]: Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this [failed invocation](https://github.com/google/yamlfmt/pull/220/checks?check_run_id=33254710224) of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.
octo: I'm working on getting the CLA sorted. Luckily there seems to be an existing agreement, so it should just be a matter of getting added to the right group.
octo: > Thank you for the feature! This is the exact sort of thing I was hoping the `-output_format` feature would be used for!

You're very welcome! Thank _you_ for putting *yamlfmt* together and being so responsive to PRs!

> Would you be able to also add an integration test for this?

✅ done
octo: It's already been pushed @braydonk ;)
braydonk: Aha we were working at the same time. Thanks!
octo: Okay, the good news is the technical issues are sorted 🎉 

How about the license check? I think I can add the Apache license header without involving legal – it's the same license as the rest of the project after all, so common operating procedure. Not so sure about changing the copyright to "Google LLC" though.
braydonk: Based on reading internal guidance, I think adding just the Apache2 license header with no copyright statements should be fine.
octo: ✅ done

`make addlicense_check` passes locally.
braydonk: Awesome, thanks for working with me on the CI stuff. I appreciate the contribution!

Patch:
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/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"
+)


Test patch:
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_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)
+			}
+		})
+	}
+}