Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions pkg/analysis/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package output
import (
"bytes"
"encoding/json"
"strings"

"github.com/fatih/color"

Expand Down Expand Up @@ -91,6 +92,74 @@ var MarshalCLI = marshalerFunc(func(data analysis.Diagnostics) ([]byte, error) {
return buf.Bytes(), nil
})

// MarshalGHA is a Marshaler that returns the diagnostics data in GitHub Actions workflow commands format.
// See GitHub Actions docs for more information:
// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#setting-a-notice-message
var MarshalGHA = marshalerFunc(func(data analysis.Diagnostics) ([]byte, error) {
var buf bytes.Buffer
for name := range data {
for _, d := range data[name] {
var readableSeverity string
switch d.Severity {
case analysis.Error:
buf.WriteString("::error ")
readableSeverity = "Error"
case analysis.Warning, analysis.SuspectedProblem:
buf.WriteString("::warning ")
readableSeverity = "Warning"
case analysis.Recommendation:
buf.WriteString("::notice ")
readableSeverity = "Recommendation"
case analysis.OK:
buf.WriteString("::debug::")
readableSeverity = "OK"
}

// Simpler title for GHA if we don't have details in the diagnostics
ghaTitleFallback := "plugin-validator: " + readableSeverity

// Final GHA annotation output (title and message)
ghaTitle := ghaTitleFallback
var ghaMessage string

// If we have a more accurate title in the diagnostic, use it as the ghaTitle
diagnosticsTitle := d.Title
if d.Context != "" {
// Add context to the ghaTitle, if we have it
diagnosticsTitle = d.Context + ": " + diagnosticsTitle
}
if diagnosticsTitle != "" {
ghaTitle += ": " + diagnosticsTitle
}

if d.Detail != "" {
// If we have details, use them as the ghaMessage
ghaMessage = d.Detail
} else {
// If we don't have details, use what the diagnostics title as message
// and go back to the fallback ghaTitle ("plugin-validator: <severity level>")
// to avoid repetition.
ghaMessage = diagnosticsTitle
ghaTitle = ghaTitleFallback
}
buf.WriteString("title=")
buf.WriteString(ghaEscape(ghaTitle))
buf.WriteString("::")
buf.WriteString(ghaEscape(ghaMessage))
buf.WriteRune('\n')
}
}
return buf.Bytes(), nil
})

var ghaEscapeReplacer = strings.NewReplacer("::", "\\:\\:", "=", "\\=")

// ghaEscape removes all characters that can mess with the GHA workflow commands syntax while outputting annotations.
// This function should be called on each part of the GHA output (title, message, etc...) before outputting them.
func ghaEscape(s string) string {
return ghaEscapeReplacer.Replace(s)
}

// ExitCode returns the exit code of the CLI program.
// It returns:
// 1 if there's an error;
Expand Down
196 changes: 196 additions & 0 deletions pkg/analysis/output/output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package output

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/grafana/plugin-validator/pkg/analysis"
)

func TestGHAOutput(t *testing.T) {
for _, tc := range []struct {
name string
diags analysis.Diagnostics
exp string
}{
{
name: "error with title and details",
diags: analysis.Diagnostics{
"analyzer1": {
{
Name: "rule1",
Severity: analysis.Error,
Title: "Test error",
Detail: "This is a test error detail",
},
},
},
exp: "::error title=plugin-validator: Error: Test error::This is a test error detail\n",
},
{
name: "error with title and without details",
diags: analysis.Diagnostics{
"analyzer1": {
{
Name: "rule1",
Severity: analysis.Error,
Title: "Test error",
},
},
},
exp: "::error title=plugin-validator: Error::Test error\n",
},
{
name: "error without title and with details",
diags: analysis.Diagnostics{
"analyzer1": {
{
Name: "rule1",
Severity: analysis.Error,
Detail: "This is a test error detail",
},
},
},
exp: "::error title=plugin-validator: Error::This is a test error detail\n",
},
{
name: "warning",
diags: analysis.Diagnostics{
"analyzer1": {
{
Name: "rule1",
Severity: analysis.Warning,
Title: "Test warning",
Detail: "This is a test warning detail",
},
},
},
exp: "::warning title=plugin-validator: Warning: Test warning::This is a test warning detail\n",
},
{
name: "recommendation",
diags: analysis.Diagnostics{
"analyzer1": {
{
Name: "rule1",
Severity: analysis.Recommendation,
Title: "Test recommendation",
Detail: "This is a test recommendation detail",
},
},
},
exp: "::notice title=plugin-validator: Recommendation: Test recommendation::This is a test recommendation detail\n",
},
{
name: "ok debug",
diags: analysis.Diagnostics{
"analyzer1": {
{
Name: "rule1",
Severity: analysis.OK,
Title: "Test ok",
Detail: "This is a test ok detail",
},
},
},
exp: "::debug::title=plugin-validator: OK: Test ok::This is a test ok detail\n",
},
} {
t.Run(tc.name, func(t *testing.T) {
out, err := MarshalGHA(tc.diags)
require.NoError(t, err)
require.Equal(t, tc.exp, string(out))
})
}
}

func TestExitCode(t *testing.T) {
for _, tc := range []struct {
name string
diags analysis.Diagnostics
strict bool
exp int
}{
{name: "empty", diags: analysis.Diagnostics{}, exp: 0},
{name: "empty strictr", diags: analysis.Diagnostics{}, strict: true, exp: 0},
{
name: "only ok",
diags: analysis.Diagnostics{
"analyzer1": {
{Severity: analysis.OK},
{Severity: analysis.OK},
},
},
exp: 0,
},
{
name: "only ok strict",
diags: analysis.Diagnostics{
"analyzer1": {
{Severity: analysis.OK},
{Severity: analysis.OK},
},
},
strict: true,
exp: 0,
},
{
name: "only recommendation",
diags: analysis.Diagnostics{
"analyzer1": {
{Severity: analysis.Recommendation},
{Severity: analysis.Recommendation},
},
},
exp: 0,
},
{
name: "only recommendation strict",
diags: analysis.Diagnostics{
"analyzer1": {
{Severity: analysis.Recommendation},
{Severity: analysis.Recommendation},
},
},
strict: true,
exp: 0,
},
{
name: "warning present not strict should exit with 0",
diags: analysis.Diagnostics{
"analyzer1": {
{Severity: analysis.OK},
{Severity: analysis.Warning},
},
},
exp: 0,
},
{
name: "warning present strict should exit with 1",
diags: analysis.Diagnostics{
"analyzer1": {
{Severity: analysis.OK},
{Severity: analysis.Warning},
},
},
strict: true,
exp: 1,
},
{
name: "error present",
diags: analysis.Diagnostics{
"analyzer1": {
{Severity: analysis.OK},
{Severity: analysis.Error},
},
},
exp: 1,
},
} {
t.Run(tc.name, func(t *testing.T) {
code := ExitCode(tc.strict, tc.diags)
require.Equal(t, tc.exp, code)
})
}
}
8 changes: 8 additions & 0 deletions pkg/cmd/plugincheck2/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,17 @@ func main() {

// Stdout/Stderr output.

// Check that the config is valid
if cfg.Global.JSONOutput && cfg.Global.GHAOutput {
logme.Errorln("can't have more than one output type set to true")
os.Exit(1)
}

// Determine the correct marshaler depending on the config
if cfg.Global.JSONOutput {
outputMarshaler = output.NewJSONMarshaler(pluginID, pluginVersion)
} else if cfg.Global.GHAOutput {
outputMarshaler = output.MarshalGHA
} else {
outputMarshaler = output.MarshalCLI
}
Expand Down
1 change: 1 addition & 0 deletions pkg/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type GlobalConfig struct {
Enabled bool `yaml:"enabled"`
Severity analysis.Severity `yaml:"severity"`
JSONOutput bool `yaml:"jsonOutput"`
GHAOutput bool `yaml:"ghaOutput"`
ReportAll bool `yaml:"reportAll"`
}

Expand Down