Skip to content

Commit

Permalink
output: Add the gitlab output. (#220)
Browse files Browse the repository at this point in the history
* 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.

* internal/gitlab: Remove custom JSON (un)marshalling.

This makes the code much simpler, at the cost of exporting an extra type.

* integrationtest: Add integration test for `gitlab_output`.

* output: Implement sorting using the `sort` package.

The `slices` package added recently, in Go 1.23.

* internal/gitlab: Add Apache 2 license header.
  • Loading branch information
octo authored Nov 21, 2024
1 parent 1c3f200 commit fab8f29
Show file tree
Hide file tree
Showing 11 changed files with 294 additions and 1 deletion.
37 changes: 36 additions & 1 deletion docs/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,39 @@ Example:
x.yaml: formatting difference found
y.yaml: formatting difference found
z.yaml: formatting difference found
```
```

## `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.
51 changes: 51 additions & 0 deletions engine/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,21 @@
package engine

import (
"encoding/json"
"fmt"
"sort"
"strings"

"github.com/google/yamlfmt"
"github.com/google/yamlfmt/internal/gitlab"
)

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) {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}
8 changes: 8 additions & 0 deletions integrationtest/command/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Test case "gitlab_output"

needs: "no-op"
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Test case "gitlab_output"


needs: "reformatting"
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Test case "gitlab_output"

needs: "no-op"
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Test case "gitlab_output"


needs: "reformatting"
Empty file.
11 changes: 11 additions & 0 deletions integrationtest/command/testdata/gitlab_output/stdout/stdout.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[
{
"description": "Not formatted correctly, run yamlfmt to resolve.",
"check_name": "yamlfmt",
"fingerprint": "e9b14e45ca01a9a72fda9b8356a9ddbbaf7fe8c47116790a51cd699ae1679353",
"severity": "major",
"location": {
"path": "needs_format.yaml"
}
}
]
79 changes: 79 additions & 0 deletions internal/gitlab/codequality.go
Original file line number Diff line number Diff line change
@@ -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"
)
95 changes: 95 additions & 0 deletions internal/gitlab/codequality_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}

0 comments on commit fab8f29

Please sign in to comment.