diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..ea86020 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,61 @@ +version: "2" +linters: + default: none + enable: + - bidichk + - errcheck + - errorlint + - forbidigo + - govet + - ineffassign + - misspell + - revive + - staticcheck + - unused + - whitespace + - zerologlint + settings: + errorlint: + errorf-multi: true + forbidigo: + forbid: + - pattern: context\.WithCancel$ + - pattern: ^print.*$ + misspell: + locale: US + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gci + - gofmt + - gofumpt + settings: + gci: + sections: + - standard + - default + - prefix(go.woodpecker-ci.org/woodpecker) + custom-order: true + gofmt: + simplify: true + rewrite-rules: + - pattern: interface{} + replacement: any + gofumpt: + extra-rules: true + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml index 63aef11..b3d06da 100644 --- a/.woodpecker/lint.yaml +++ b/.woodpecker/lint.yaml @@ -3,32 +3,22 @@ when: - event: push branch: - ${CI_REPO_DEFAULT_BRANCH} - - 'renovate/*' + - "renovate/*" variables: - &golang "golang:1.24" - - &golangci-lint "golangci/golangci-lint:v2.0-alpine" - - &reviewdog-golangci-lint "woodpeckerci/plugin-reviewdog-golangci-lint:1.61" steps: vendor: image: *golang commands: go mod vendor - review-go: - image: *reviewdog-golangci-lint - settings: - token: - from_secret: reviewdog_token - when: - event: pull_request - lint: - image: *golangci-lint - commands: golangci-lint run --timeout 5m + image: *golang + commands: make lint when: event: [push, tag, cron] test: image: *golang - commands: go test --cover ./... + commands: make test diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..53cf2ad --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +GOFILES_NOVENDOR = $(shell find . -type f -name '*.go' -not -path "./vendor/*" -not -path "./.git/*") +GO_PACKAGES ?= $(shell go list ./... | grep -v /vendor/) + +.PHONY: all +all: lint + +vendor: + go mod tidy + go mod vendor + +format: install-tools ## Format source code + @gofumpt -extra -w ${GOFILES_NOVENDOR} + +formatcheck: + @([ -z "$(shell gofumpt -d $(GOFILES_NOVENDOR) | head)" ]) || (echo "Source is unformatted"; exit 1) + +install-tools: ## Install development tools + @hash golangci-lint > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest ; \ + fi ; \ + hash gofumpt > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ + go install mvdan.cc/gofumpt@latest; \ + fi ; \ + +.PHONY: clean +clean: + go clean -i ./... + +.PHONY: lint +lint: install-tools ## Lint code + @echo "Running golangci-lint" + golangci-lint run + +.PHONY: vet +vet: + @echo "Running go vet..." + @go vet $(GO_PACKAGES) + +.PHONY: test +test: + go test -race -cover ./... diff --git a/cli/flag_string_map.go b/cli/flag_string_map.go new file mode 100644 index 0000000..37cd374 --- /dev/null +++ b/cli/flag_string_map.go @@ -0,0 +1,84 @@ +package cli + +import ( + "encoding/json" + + "github.com/urfave/cli/v3" +) + +// StringMapFlag is a flag type which supports JSON string maps. +type ( + StringMapFlag = cli.FlagBase[map[string]string, StringMapConfig, StringMap] +) + +// StringMapConfig defines the configuration for string map flags. +type StringMapConfig struct { + // Any config options can be added here if needed +} + +// StringMap implements the Value and ValueCreator interfaces for string maps. +type StringMap struct { + destination *map[string]string +} + +// Create implements the ValueCreator interface. +func (s StringMap) Create(v map[string]string, p *map[string]string, _ StringMapConfig) cli.Value { + *p = map[string]string{} + + if v != nil { + *p = v + } + + return &StringMap{ + destination: p, + } +} + +// ToString implements the ValueCreator interface. +func (s StringMap) ToString(v map[string]string) string { + if len(v) == 0 { + return "" + } + + jsonBytes, err := json.Marshal(v) + if err != nil { + return "" + } + + return string(jsonBytes) +} + +// Set implements the flag.Value interface. +func (s *StringMap) Set(v string) error { + *s.destination = map[string]string{} + + if v == "" { + return nil + } + + err := json.Unmarshal([]byte(v), s.destination) + if err != nil { + (*s.destination)["*"] = v + } + + return nil +} + +// Get implements the flag.Value interface. +func (s *StringMap) Get() any { + return *s.destination +} + +// String implements the flag.Value interface. +func (s *StringMap) String() string { + if s.destination == nil || len(*s.destination) == 0 { + return "" + } + + jsonBytes, err := json.Marshal(*s.destination) + if err != nil { + return "" + } + + return string(jsonBytes) +} diff --git a/cli/flag_string_map_test.go b/cli/flag_string_map_test.go new file mode 100644 index 0000000..1f87fe3 --- /dev/null +++ b/cli/flag_string_map_test.go @@ -0,0 +1,221 @@ +package cli + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStringMapSet(t *testing.T) { + tests := []struct { + name string + input string + want map[string]string + }{ + { + name: "empty string", + input: "", + want: map[string]string{}, + }, + { + name: "valid JSON", + input: `{"key1":"value1","key2":"value2"}`, + want: map[string]string{"key1": "value1", "key2": "value2"}, + }, + { + name: "single key-value", + input: `{"key":"value"}`, + want: map[string]string{"key": "value"}, + }, + { + name: "non-JSON string", + input: "not-json", + want: map[string]string{"*": "not-json"}, + }, + { + name: "empty JSON object", + input: "{}", + want: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var dest map[string]string + s := &StringMap{ + destination: &dest, + } + + err := s.Set(tt.input) + assert.NoError(t, err) + assert.Equal(t, tt.want, dest) + }) + } +} + +func TestStringMapString(t *testing.T) { + tests := []struct { + name string + input map[string]string + want string + }{ + { + name: "empty map", + input: map[string]string{}, + want: "", + }, + { + name: "nil map", + input: nil, + want: "", + }, + { + name: "single key-value", + input: map[string]string{"key": "value"}, + want: `{"key":"value"}`, + }, + { + name: "multiple key-values", + input: map[string]string{"key1": "value1", "key2": "value2"}, + want: `{"key1":"value1","key2":"value2"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &StringMap{ + destination: &tt.input, + } + + got := s.String() + + if len(tt.input) > 1 { + var expected, actual map[string]string + _ = json.Unmarshal([]byte(tt.want), &expected) + _ = json.Unmarshal([]byte(got), &actual) + assert.EqualValues(t, expected, actual) + + return + } + + assert.Equal(t, tt.want, got) + }) + } +} + +func TestStringMapGet(t *testing.T) { + tests := []struct { + name string + want map[string]string + }{ + { + name: "empty map", + want: map[string]string{}, + }, + { + name: "single key-value", + want: map[string]string{"key": "value"}, + }, + { + name: "multiple key-values", + want: map[string]string{"key1": "value1", "key2": "value2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &StringMap{ + destination: &tt.want, + } + + result := s.Get() + assert.Equal(t, tt.want, result) + }) + } +} + +func TestStringMapCreate(t *testing.T) { + tests := []struct { + name string + input map[string]string + want map[string]string + }{ + { + name: "empty map", + input: nil, + want: map[string]string{}, + }, + { + name: "empty map", + input: map[string]string{}, + want: map[string]string{}, + }, + { + name: "single key-value", + input: map[string]string{"key": "value"}, + want: map[string]string{"key": "value"}, + }, + { + name: "multiple key-values", + input: map[string]string{"key1": "value1", "key2": "value2"}, + want: map[string]string{"key1": "value1", "key2": "value2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var dest map[string]string + + s := StringMap{} + config := StringMapConfig{} + + got := s.Create(tt.input, &dest, config) + assert.Equal(t, tt.want, dest) + assert.Equal(t, &dest, got.(*StringMap).destination) + }) + } +} + +func TestStringMapToString(t *testing.T) { + tests := []struct { + name string + input map[string]string + want string + }{ + { + name: "empty map", + input: map[string]string{}, + want: "", + }, + { + name: "single key-value", + input: map[string]string{"key": "value"}, + want: `{"key":"value"}`, + }, + { + name: "multiple key-values", + input: map[string]string{"key1": "value1", "key2": "value2"}, + want: `{"key1":"value1","key2":"value2"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := StringMap{} + + got := s.ToString(tt.input) + + if len(tt.input) > 1 { + var expected, actual map[string]string + _ = json.Unmarshal([]byte(tt.want), &expected) + _ = json.Unmarshal([]byte(got), &actual) + assert.EqualValues(t, expected, actual) + + return + } + + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/cli/flag_string_slice.go b/cli/flag_string_slice.go new file mode 100644 index 0000000..d29c14b --- /dev/null +++ b/cli/flag_string_slice.go @@ -0,0 +1,83 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/urfave/cli/v3" +) + +// StringSliceFlag is a flag type which support comma separated values and escaping to not split at unwanted lines. +type ( + StringSliceFlag = cli.FlagBase[[]string, StringSliceConfig, StringSlice] +) + +// StringConfig defines the configuration for string flags. +type StringSliceConfig struct { + Delimiter string + EscapeString string +} + +// StringSlice implements the Value and ValueCreator interfaces for string slices. +type StringSlice struct { + destination *[]string + delimiter string + escapeString string +} + +// Create implements the ValueCreator interface. +func (s StringSlice) Create(v []string, p *[]string, c StringSliceConfig) cli.Value { + *p = v + + return &StringSlice{ + destination: p, + delimiter: c.Delimiter, + escapeString: c.EscapeString, + } +} + +// ToString implements the ValueCreator interface. +func (s StringSlice) ToString(v []string) string { + if len(v) == 0 { + return "" + } + + return fmt.Sprintf("%q", strings.Join(v, s.delimiter)) +} + +// Set implements the flag.Value interface. +func (s *StringSlice) Set(v string) error { + if v == "" { + *s.destination = []string{} + + return nil + } + + out := strings.Split(v, s.delimiter) + + //nolint:mnd + for i := len(out) - 2; i >= 0; i-- { + if strings.HasSuffix(out[i], s.escapeString) { + out[i] = out[i][:len(out[i])-len(s.escapeString)] + s.delimiter + out[i+1] + out = append(out[:i+1], out[i+2:]...) + } + } + + *s.destination = out + + return nil +} + +// Get implements the flag.Value interface. +func (s *StringSlice) Get() any { + return *s.destination +} + +// String implements the flag.Value interface. +func (s *StringSlice) String() string { + if s.destination == nil || len(*s.destination) == 0 { + return "" + } + + return strings.Join(*s.destination, s.delimiter) +} diff --git a/cli/flag_string_slice_test.go b/cli/flag_string_slice_test.go new file mode 100644 index 0000000..23e5bf2 --- /dev/null +++ b/cli/flag_string_slice_test.go @@ -0,0 +1,225 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStringSliceSet(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + { + name: "empty string", + input: "", + want: []string{}, + }, + { + name: "simple comma separated", + input: "a,b", + want: []string{"a", "b"}, + }, + { + name: "multiple commas", + input: ",,,", + want: []string{"", "", "", ""}, + }, + { + name: "escaped comma", + input: ",a\\,", + want: []string{"", "a,"}, + }, + { + name: "escaped backslash", + input: "a,b\\,c\\\\d,e", + want: []string{"a", "b,c\\\\d", "e"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got []string + s := &StringSlice{ + destination: &got, + delimiter: ",", + escapeString: "\\", + } + + err := s.Set(tt.input) + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestStringSliceString(t *testing.T) { + tests := []struct { + name string + input []string + want string + }{ + { + name: "empty slice", + input: []string{}, + want: "", + }, + { + name: "nil slice", + input: nil, + want: "", + }, + { + name: "single item", + input: []string{"a"}, + want: "a", + }, + { + name: "multiple items", + input: []string{"a", "b", "c"}, + want: "a,b,c", + }, + { + name: "items with commas", + input: []string{"a,b", "c"}, + want: "a,b,c", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &StringSlice{ + destination: &tt.input, + delimiter: ",", + escapeString: "\\", + } + + assert.Equal(t, tt.want, s.String()) + }) + } +} + +func TestStringSliceGet(t *testing.T) { + tests := []struct { + name string + want []string + }{ + { + name: "empty slice", + want: []string{}, + }, + { + name: "single item", + want: []string{"a"}, + }, + { + name: "multiple items", + want: []string{"a", "b", "c"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &StringSlice{ + destination: &tt.want, + delimiter: ",", + escapeString: "\\", + } + + result := s.Get() + assert.Equal(t, tt.want, result) + }) + } +} + +func TestStringSliceCreate(t *testing.T) { + tests := []struct { + name string + input []string + want []string + config StringSliceConfig + }{ + { + name: "empty slice", + input: nil, + want: []string{}, + }, + { + name: "default config", + input: []string{"a", "b"}, + want: []string{"a", "b"}, + config: StringSliceConfig{ + Delimiter: ",", + EscapeString: "\\", + }, + }, + { + name: "custom config", + input: []string{"a", "b"}, + want: []string{"a", "b"}, + config: StringSliceConfig{ + Delimiter: ";", + EscapeString: "#", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var dest []string + + s := StringSlice{} + got := s.Create(tt.input, &dest, tt.config) + + assert.Equal(t, tt.input, dest) + assert.Equal(t, &dest, got.(*StringSlice).destination) + assert.Equal(t, tt.config.Delimiter, got.(*StringSlice).delimiter) + assert.Equal(t, tt.config.EscapeString, got.(*StringSlice).escapeString) + }) + } +} + +func TestStringSliceToString(t *testing.T) { + tests := []struct { + name string + input []string + delimiter string + want string + }{ + { + name: "empty slice", + input: []string{}, + delimiter: ",", + want: "", + }, + { + name: "single item", + input: []string{"a"}, + delimiter: ",", + want: `"a"`, + }, + { + name: "multiple items", + input: []string{"a", "b", "c"}, + delimiter: ",", + want: `"a,b,c"`, + }, + { + name: "custom delimiter", + input: []string{"a", "b", "c"}, + delimiter: ";", + want: `"a;b;c"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := StringSlice{delimiter: tt.delimiter} + + got := s.ToString(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/metadata_test.go b/metadata_test.go index ad9877b..27ad758 100644 --- a/metadata_test.go +++ b/metadata_test.go @@ -16,7 +16,6 @@ package plugin import ( "context" - "os" "testing" "time" @@ -72,11 +71,10 @@ func testMetadata() map[string]string { func TestMetadata(t *testing.T) { for k, v := range testMetadata() { - os.Setenv(k, v) - defer os.Unsetenv(k) + t.Setenv(k, v) } plugin := New(Options{ - Execute: func(ctx context.Context) error { + Execute: func(_ context.Context) error { return nil }, }) diff --git a/plugin_test.go b/plugin_test.go index c815146..b99626b 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -25,7 +25,7 @@ func TestPlugin(t *testing.T) { var executed bool p := New(Options{ Name: "test", - Execute: func(ctx context.Context) error { + Execute: func(_ context.Context) error { executed = true return nil },