diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 2dd1565..3986c40 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -17,7 +17,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.23' + go-version: 'stable' - name: Check out code into the Go module directory uses: actions/checkout@v4 diff --git a/Dockerfile b/Dockerfile index da5c6be..cb41ab7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,25 @@ -FROM golang:1.23.3 AS build-env +FROM --platform=$BUILDPLATFORM golang:1.23.3 AS build-env -ARG ACTION_VERSION=unknown -ARG REVIVE_VERSION=v1.5.0 +ARG VERSION -ENV CGO_ENABLED=0 +ARG TARGETOS +ARG TARGETARCH -RUN go install -v -ldflags="-X 'github.com/mgechev/revive/cli.version=${REVIVE_VERSION}'" \ - github.com/mgechev/revive@${REVIVE_VERSION} +ENV CGO_ENABLED=0 -WORKDIR /tmp/github.com/morphy2k/revive-action +WORKDIR /src COPY . . -RUN go install -ldflags="-X 'main.version=${ACTION_VERSION}'" +RUN GOOS=$TARGETOS GOARCH=$TARGETARCH \ + go build -ldflags="-X 'main.version=${VERSION}'" -FROM alpine:3.20.3 +FROM ghcr.io/mgechev/revive:1.5.0 LABEL repository="https://github.com/morphy2k/revive-action" LABEL homepage="https://github.com/morphy2k/revive-action" LABEL maintainer="Markus Wiegand " +LABEL org.opencontainers.image.title="Revive Action" LABEL org.opencontainers.image.source="https://github.com/morphy2k/revive-action" LABEL org.opencontainers.image.description="GitHub Action that runs Revive on your Go code" LABEL org.opencontainers.image.licenses=MIT @@ -28,9 +29,6 @@ LABEL com.github.actions.description="GitHub Action that runs Revive on your Go LABEL com.github.actions.icon="code" LABEL com.github.actions.color="blue" -COPY --from=build-env ["/go/bin/revive", "/go/bin/revive-action", "/bin/"] -COPY --from=build-env /tmp/github.com/morphy2k/revive-action/entrypoint.sh / - -RUN apk add --no-cache bash gawk +COPY --from=build-env ["/src/revive-action", "/revive-action"] -ENTRYPOINT ["/entrypoint.sh"] +ENTRYPOINT ["/revive-action"] diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100755 index 00ed2cf..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -set -e -set -o pipefail - -cd "$GITHUB_WORKSPACE" - -ACTION_VERSION=$(revive-action -version) -REVIVE_VERSION=$(revive -version | gawk '{match($0,"v[0-9].[0-9].[0-9]",a)}END{print a[0]}') - -LINT_PATH="./..." - -if [ ! -z "${INPUT_PATH}" ]; then LINT_PATH=$INPUT_PATH; fi - -IFS=';' read -ra ADDR <<< "$INPUT_EXCLUDE" -for i in "${ADDR[@]}"; do - EXCLUDES="$EXCLUDES -exclude="$i"" -done - -if [ ! -z "${INPUT_CONFIG}" ]; then CONFIG="-config=$INPUT_CONFIG"; fi - -echo "ACTION: $ACTION_VERSION -REVIVE: $REVIVE_VERSION" - -eval "revive $CONFIG $EXCLUDES -formatter ndjson $LINT_PATH | revive-action" diff --git a/failure.go b/failure.go new file mode 100644 index 0000000..d2ed068 --- /dev/null +++ b/failure.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "go/token" + "strings" +) + +type failure struct { + Position struct { + Start token.Position + End token.Position + } + Failure string + Severity string +} + +func (f *failure) Format() string { + var sb strings.Builder + + if f.Severity == "warning" { + sb.WriteString("::warning ") + } else { + sb.WriteString("::error ") + } + + fmt.Fprintf(&sb, "file=%s,line=%d,endLine=%d,col=%d,endColumn=%d::%s", + f.Position.Start.Filename, f.Position.Start.Line, f.Position.End.Line, f.Position.Start.Column, f.Position.End.Column, f.Failure) + + return sb.String() +} + +type statistics struct { + Total, Warnings, Errors int +} + +func (s statistics) String() string { + return fmt.Sprintf("%d failures (%d warnings, %d errors)", + s.Total, s.Warnings, s.Errors) +} diff --git a/failure_test.go b/failure_test.go new file mode 100644 index 0000000..f25de4a --- /dev/null +++ b/failure_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "go/token" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormat(t *testing.T) { + tests := []struct { + name string + failure failure + expected string + }{ + { + name: "error severity", + failure: failure{ + Position: struct { + Start token.Position + End token.Position + }{ + Start: token.Position{Filename: "main.go", Line: 10, Column: 5}, + End: token.Position{Line: 10, Column: 10}, + }, + Failure: "some error", + Severity: "error", + }, + expected: "::error file=main.go,line=10,endLine=10,col=5,endColumn=10::some error", + }, + { + name: "warning severity", + failure: failure{ + Position: struct { + Start token.Position + End token.Position + }{ + Start: token.Position{Filename: "main.go", Line: 20, Column: 15}, + End: token.Position{Line: 20, Column: 20}, + }, + Failure: "some warning", + Severity: "warning", + }, + expected: "::warning file=main.go,line=20,endLine=20,col=15,endColumn=20::some warning", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.failure.Format() + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/go.mod b/go.mod index 556980e..c4fcbaa 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,11 @@ module github.com/morphy2k/revive-action -go 1.16 +go 1.23 + +require github.com/stretchr/testify v1.9.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index e69de29..60ce688 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/input.go b/input.go new file mode 100644 index 0000000..1c334e1 --- /dev/null +++ b/input.go @@ -0,0 +1,37 @@ +package main + +import ( + "os" + "strings" +) + +const ( + defaultPath = "./..." +) + +type input struct { + exclude []string + config string + path string +} + +func parseInput() *input { + input := &input{ + exclude: make([]string, 0), + path: defaultPath, + } + + if v, ok := os.LookupEnv("INPUT_EXCLUDE"); ok { + input.exclude = strings.Split(v, ";") + } + + if v, ok := os.LookupEnv("INPUT_CONFIG"); ok { + input.config = v + } + + if v, ok := os.LookupEnv("INPUT_PATH"); ok { + input.path = v + } + + return input +} diff --git a/input_test.go b/input_test.go new file mode 100644 index 0000000..1ff72f3 --- /dev/null +++ b/input_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseInput(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + want *input + }{ + { + name: "default values", + envVars: map[string]string{}, + want: &input{ + exclude: []string{}, + config: "", + path: defaultPath, + }, + }, + { + name: "exclude with single value", + envVars: map[string]string{ + "INPUT_EXCLUDE": "test", + }, + want: &input{ + exclude: []string{"test"}, + config: "", + path: defaultPath, + }, + }, + { + name: "exclude with multiple values", + envVars: map[string]string{ + "INPUT_EXCLUDE": "test1;test2;test3", + }, + want: &input{ + exclude: []string{"test1", "test2", "test3"}, + config: "", + path: defaultPath, + }, + }, + { + name: "config with value", + envVars: map[string]string{ + "INPUT_CONFIG": "config.yaml", + }, + want: &input{ + exclude: []string{}, + config: "config.yaml", + path: defaultPath, + }, + }, + { + name: "custom path", + envVars: map[string]string{ + "INPUT_PATH": "./custom", + }, + want: &input{ + exclude: []string{}, + config: "", + path: "./custom", + }, + }, + { + name: "all values set", + envVars: map[string]string{ + "INPUT_EXCLUDE": "test1;test2", + "INPUT_CONFIG": "config.yaml", + "INPUT_PATH": "./custom", + }, + want: &input{ + exclude: []string{"test1", "test2"}, + config: "config.yaml", + path: "./custom", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Clearenv() + for k, v := range tt.envVars { + os.Setenv(k, v) + } + defer os.Clearenv() + + got := parseInput() + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/main.go b/main.go index 007acc5..f4bd8b0 100644 --- a/main.go +++ b/main.go @@ -3,64 +3,101 @@ package main import ( "encoding/json" + "errors" "flag" "fmt" - "go/token" "os" - "sync" + "os/exec" + "strings" ) -var version = "unknown" +const formatter = "ndjson" -type failure struct { - Failure string - RuleName string - Category string - Position position - Confidence float64 - Severity string -} +var version = "" -type position struct { - Start token.Position - End token.Position -} +func runRevive(args []string) (*statistics, int, error) { + cmd := exec.Command("revive", args...) -type statistics struct { - Total, Warnings, Errors int -} + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, 0, fmt.Errorf("error while getting stdout pipe: %w", err) + } + defer stdout.Close() -func (f statistics) String() string { - return fmt.Sprintf("%d failures (%d warnings, %d errors)", - f.Total, f.Warnings, f.Errors) -} + if err := cmd.Start(); err != nil { + return nil, 0, fmt.Errorf("error while running revive: %w", err) + } + + dec := json.NewDecoder(stdout) -func getFailures(ch chan *failure) { - dec := json.NewDecoder(os.Stdin) + stats := &statistics{} + + fmt.Println("::group::Failures") for dec.More() { f := &failure{} if err := dec.Decode(f); err != nil { - fmt.Fprintln(os.Stderr, "Error while decoding stdin:", err) - os.Exit(1) + fmt.Println("::endgroup::") + return nil, 0, fmt.Errorf("error while decoding revive output: %w", err) } - ch <- f + + stats.Total++ + + switch f.Severity { + case "warning": + stats.Warnings++ + case "error": + stats.Errors++ + } + + fmt.Println(f.Format()) + } + + fmt.Println("::endgroup::") + + var exitErr *exec.ExitError + if err := cmd.Wait(); err != nil && !errors.As(err, &exitErr) { + return nil, 0, fmt.Errorf("error while waiting for revive: %w", err) + } + + code := cmd.ProcessState.ExitCode() + + return stats, code, nil +} + +func getReviveVersion() (string, error) { + cmd := exec.Command("revive", "-version") + + stdout, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("error while getting revive version: %w", err) } - close(ch) + output := strings.TrimSpace(string(stdout)) + parts := strings.Fields(output) + if len(parts) < 2 { + return "", fmt.Errorf("unexpected output format: %s", output) + } + + version := parts[1] + + return version, nil } -func printFailure(f *failure, wg *sync.WaitGroup) { - s := fmt.Sprintf("file=%s,line=%d,endLine=%d,col=%d,endColumn=%d::%s\n", - f.Position.Start.Filename, f.Position.Start.Line, f.Position.End.Line, f.Position.Start.Column, f.Position.End.Column, f.Failure) +func buildArgs(input *input) []string { + args := []string{"-formatter", formatter} + + if input.config != "" { + args = append(args, "-config", input.config) + } - if f.Severity == "warning" { - fmt.Printf("::warning %s", s) - } else { - fmt.Printf("::error %s", s) + for _, path := range input.exclude { + args = append(args, "-exclude", path) } - wg.Done() + args = append(args, input.path) + + return args } func main() { @@ -72,33 +109,24 @@ func main() { os.Exit(0) } - stats := &statistics{} - - ch := make(chan *failure) - go getFailures(ch) - - wg := &sync.WaitGroup{} - - fmt.Println("::group::Failures") + input := parseInput() + args := buildArgs(input) - for f := range ch { - wg.Add(1) - - stats.Total++ - - switch f.Severity { - case "warning": - stats.Warnings++ - case "error": - stats.Errors++ - } - - go printFailure(f, wg) + reviveVersion, err := getReviveVersion() + if err != nil { + fmt.Fprintf(os.Stderr, "::error %s", err) + os.Exit(1) } - wg.Wait() + fmt.Printf("ACTION: %s\nREVIVE: %s\n", version, reviveVersion) - fmt.Println("::endgroup::") + stats, code, err := runRevive(args) + if err != nil { + fmt.Fprintf(os.Stderr, "::error %s", err) + os.Exit(1) + } fmt.Println("Successful run with", stats.String()) + + os.Exit(code) }