Skip to content

Commit 89668c0

Browse files
committed
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.
1 parent 1c3f200 commit 89668c0

File tree

4 files changed

+278
-1
lines changed

4 files changed

+278
-1
lines changed

docs/output.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,39 @@ Example:
3131
x.yaml: formatting difference found
3232
y.yaml: formatting difference found
3333
z.yaml: formatting difference found
34-
```
34+
```
35+
36+
## `gitlab`
37+
38+
Generates a [GitLab Code Quality report](https://docs.gitlab.com/ee/ci/testing/code_quality.html#code-quality-report-format).
39+
40+
Example:
41+
42+
```json
43+
[
44+
{
45+
"description": "Not formatted correctly, run yamlfmt to resolve.",
46+
"check_name": "yamlfmt",
47+
"fingerprint": "c1dddeed9a8423b815cef59434fe3dea90d946016c8f71ecbd7eb46c528c0179",
48+
"severity": "major",
49+
"location": {
50+
"path": ".gitlab-ci.yml"
51+
}
52+
},
53+
]
54+
```
55+
56+
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.
57+
Abbreviated example:
58+
59+
```yaml
60+
yamlfmt:
61+
script:
62+
- yamlfmt -dry -output_format gitlab . >yamlfmt-report
63+
artifacts:
64+
when: always
65+
reports:
66+
codequality: yamlfmt-report
67+
```
68+
69+
With `-quiet`, the GitLab format will omit unnecessary whitespace to produce a more compact output.

engine/output.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,21 @@
1515
package engine
1616

1717
import (
18+
"encoding/json"
1819
"fmt"
20+
"slices"
21+
"strings"
1922

2023
"github.com/google/yamlfmt"
24+
"github.com/google/yamlfmt/internal/gitlab"
2125
)
2226

2327
type EngineOutputFormat string
2428

2529
const (
2630
EngineOutputDefault EngineOutputFormat = "default"
2731
EngineOutputSingeLine EngineOutputFormat = "line"
32+
EngineOutputGitlab EngineOutputFormat = "gitlab"
2833
)
2934

3035
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
3338
return engineOutput{Operation: operation, Files: files, Quiet: quiet}, nil
3439
case EngineOutputSingeLine:
3540
return engineOutputSingleLine{Operation: operation, Files: files, Quiet: quiet}, nil
41+
case EngineOutputGitlab:
42+
return engineOutputGitlab{Operation: operation, Files: files, Compact: quiet}, nil
43+
3644
}
3745
return nil, fmt.Errorf("unknown output type: %s", t)
3846
}
@@ -85,3 +93,39 @@ func (eosl engineOutputSingleLine) String() string {
8593
}
8694
return msg
8795
}
96+
97+
type engineOutputGitlab struct {
98+
Operation yamlfmt.Operation
99+
Files yamlfmt.FileDiffs
100+
Compact bool
101+
}
102+
103+
func (eo engineOutputGitlab) String() string {
104+
var findings []gitlab.CodeQuality
105+
106+
for _, file := range eo.Files {
107+
if cq, ok := gitlab.NewCodeQuality(*file); ok {
108+
findings = append(findings, cq)
109+
}
110+
}
111+
112+
if len(findings) == 0 {
113+
return ""
114+
}
115+
116+
slices.SortFunc(findings, func(a, b gitlab.CodeQuality) int {
117+
return strings.Compare(a.Path, b.Path)
118+
})
119+
120+
var b strings.Builder
121+
enc := json.NewEncoder(&b)
122+
123+
if !eo.Compact {
124+
enc.SetIndent("", " ")
125+
}
126+
127+
if err := enc.Encode(findings); err != nil {
128+
panic(err)
129+
}
130+
return b.String()
131+
}

internal/gitlab/codequality.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Package gitlab generates GitLab Code Quality reports.
2+
package gitlab
3+
4+
import (
5+
"crypto/sha256"
6+
"encoding/json"
7+
"fmt"
8+
9+
"github.com/google/yamlfmt"
10+
)
11+
12+
// CodeQuality represents a single code quality finding.
13+
//
14+
// Documentation: https://docs.gitlab.com/ee/ci/testing/code_quality.html#code-quality-report-format
15+
type CodeQuality struct {
16+
Description string
17+
Name string
18+
Fingerprint string
19+
Severity Severity
20+
Path string
21+
}
22+
23+
// NewCodeQuality creates a new CodeQuality object from a yamlfmt.FileDiff.
24+
//
25+
// If the file did not change, i.e. the diff is empty, an empty struct and false is returned.
26+
func NewCodeQuality(diff yamlfmt.FileDiff) (CodeQuality, bool) {
27+
if !diff.Diff.Changed() {
28+
return CodeQuality{}, false
29+
}
30+
31+
return CodeQuality{
32+
Description: "Not formatted correctly, run yamlfmt to resolve.",
33+
Name: "yamlfmt",
34+
Fingerprint: fingerprint(diff),
35+
Severity: Major,
36+
Path: diff.Path,
37+
}, true
38+
}
39+
40+
// Marshals a CodeQuality object into JSON.
41+
func (cq CodeQuality) MarshalJSON() ([]byte, error) {
42+
data, err := json.Marshal(wrap(cq))
43+
if err != nil {
44+
return nil, fmt.Errorf("json.Marshal: %w", err)
45+
}
46+
47+
return data, nil
48+
}
49+
50+
// UnmarshalJSON unmarshals JSON into a CodeQuality object.
51+
func (cq *CodeQuality) UnmarshalJSON(data []byte) error {
52+
var ext codeQuality
53+
if err := json.Unmarshal(data, &ext); err != nil {
54+
return fmt.Errorf("json.Unmarshal: %w", err)
55+
}
56+
57+
*cq = unwrap(ext)
58+
59+
return nil
60+
}
61+
62+
// codeQuality is the external representation of CodeQuality.
63+
// It is needed to add custom JSON marshaling and unmarshaling logic.
64+
type codeQuality struct {
65+
Description string `json:"description,omitempty"`
66+
Name string `json:"check_name,omitempty"`
67+
Fingerprint string `json:"fingerprint,omitempty"`
68+
Severity Severity `json:"severity,omitempty"`
69+
Location location `json:"location,omitempty"`
70+
}
71+
72+
type location struct {
73+
Path string `json:"path,omitempty"`
74+
}
75+
76+
func wrap(cq CodeQuality) codeQuality {
77+
return codeQuality{
78+
Description: cq.Description,
79+
Name: cq.Name,
80+
Fingerprint: cq.Fingerprint,
81+
Severity: cq.Severity,
82+
Location: location{
83+
Path: cq.Path,
84+
},
85+
}
86+
}
87+
88+
func unwrap(cq codeQuality) CodeQuality {
89+
return CodeQuality{
90+
Description: cq.Description,
91+
Name: cq.Name,
92+
Fingerprint: cq.Fingerprint,
93+
Severity: cq.Severity,
94+
Path: cq.Location.Path,
95+
}
96+
}
97+
98+
// fingerprint returns a 256-bit SHA256 hash of the original unformatted file.
99+
// This is used to uniquely identify a code quality finding.
100+
func fingerprint(diff yamlfmt.FileDiff) string {
101+
hash := sha256.New()
102+
103+
fmt.Fprint(hash, diff.Diff.Original)
104+
105+
return fmt.Sprintf("%x", hash.Sum(nil)) //nolint:perfsprint
106+
}
107+
108+
// Severity is the severity of a code quality finding.
109+
type Severity string
110+
111+
const (
112+
Info Severity = "info"
113+
Minor Severity = "minor"
114+
Major Severity = "major"
115+
Critical Severity = "critical"
116+
Blocker Severity = "blocker"
117+
)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package gitlab_test
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/google/go-cmp/cmp"
8+
"github.com/google/yamlfmt"
9+
"github.com/google/yamlfmt/internal/gitlab"
10+
)
11+
12+
func TestCodeQuality(t *testing.T) {
13+
t.Parallel()
14+
15+
cases := []struct {
16+
name string
17+
diff yamlfmt.FileDiff
18+
wantOK bool
19+
wantFingerprint string
20+
}{
21+
{
22+
name: "no diff",
23+
diff: yamlfmt.FileDiff{
24+
Path: "testcase/no_diff.yaml",
25+
Diff: &yamlfmt.FormatDiff{
26+
Original: "a: b",
27+
Formatted: "a: b",
28+
},
29+
},
30+
wantOK: false,
31+
},
32+
{
33+
name: "with diff",
34+
diff: yamlfmt.FileDiff{
35+
Path: "testcase/with_diff.yaml",
36+
Diff: &yamlfmt.FormatDiff{
37+
Original: "a: b",
38+
Formatted: "a: b",
39+
},
40+
},
41+
wantOK: true,
42+
// SHA256 of diff.Diff.Original
43+
wantFingerprint: "05088f1c296b4fd999a1efe48e4addd0f962a8569afbacc84c44630d47f09330",
44+
},
45+
}
46+
47+
for _, tc := range cases {
48+
// copy tc to avoid capturing an aliased loop variable in a Goroutine.
49+
tc := tc
50+
51+
t.Run(tc.name, func(t *testing.T) {
52+
t.Parallel()
53+
54+
got, gotOK := gitlab.NewCodeQuality(tc.diff)
55+
if gotOK != tc.wantOK {
56+
t.Fatalf("NewCodeQuality() = (%#v, %v), want (*, %v)", got, gotOK, tc.wantOK)
57+
}
58+
if !gotOK {
59+
return
60+
}
61+
62+
if tc.wantFingerprint != "" && tc.wantFingerprint != got.Fingerprint {
63+
t.Fatalf("NewCodeQuality().Fingerprint = %q, want %q", got.Fingerprint, tc.wantFingerprint)
64+
}
65+
66+
data, err := json.Marshal(got)
67+
if err != nil {
68+
t.Fatal(err)
69+
}
70+
71+
var gotUnmarshal gitlab.CodeQuality
72+
if err := json.Unmarshal(data, &gotUnmarshal); err != nil {
73+
t.Fatal(err)
74+
}
75+
76+
if diff := cmp.Diff(got, gotUnmarshal); diff != "" {
77+
t.Errorf("json.Marshal() and json.Unmarshal() mismatch (-got +want):\n%s", diff)
78+
}
79+
})
80+
}
81+
}

0 commit comments

Comments
 (0)