diff --git a/.gitignore b/.gitignore index 7a1537b..28f3a73 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .idea node_modules +commitlint-scope +build/* diff --git a/Taskfile.yaml b/Taskfile.yaml index c6e5e05..723ef53 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -24,3 +24,9 @@ tasks: - gen cmds: - go tool mockery + + build: + desc: Build + cmds: + - mkdir -p build + - go build -o build/ diff --git a/cmd/root.go b/cmd/root.go index 052fa9f..9b76474 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,15 +4,21 @@ import ( "context" "encoding/json" "fmt" + "io" "log/slog" + "os" + "github.com/fatih/color" "github.com/thumbrise/commitlint-scope/pkg/validator" "github.com/urfave/cli/v3" ) var ( - from string - to string + flagFrom string + flagTo string + flagVerbose bool + flagNoColor bool + flagJSON bool ) // Root is the entry point command for commitlint-scope. @@ -36,18 +42,41 @@ Examples: Aliases: []string{"f"}, Usage: "Start of the commit range (exclusive)", Required: true, - Destination: &from, + Destination: &flagFrom, }, &cli.StringFlag{ Name: "to", Aliases: []string{"t"}, Usage: "End of the commit range (inclusive)", Required: true, - Destination: &to, + Destination: &flagTo, + }, + &cli.BoolFlag{ + Name: "verbose", + Aliases: []string{"v"}, + Usage: "Verbose output", + Required: false, + Destination: &flagVerbose, + }, + &cli.BoolFlag{ + Name: "no-color", + Usage: "Disable color output", + Required: false, + Destination: &flagNoColor, + }, + &cli.BoolFlag{ + Name: "json", + Usage: "Show output in JSON format", + Required: false, + Destination: &flagJSON, }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + color.NoColor = flagNoColor + + configureLogger(os.Stderr, flagVerbose) + cfg, err := validator.LoadConfig() if err != nil { return fmt.Errorf("loading config: %w", err) @@ -64,7 +93,7 @@ Examples: return fmt.Errorf("creating validator: %w", err) } - violations, err := vld.Validate(ctx, from, to) + violations, err := vld.Validate(ctx, flagFrom, flagTo) if err != nil { return fmt.Errorf("validation failed: %w", err) } @@ -73,15 +102,57 @@ Examples: return nil } - encoder := json.NewEncoder(cmd.Writer) - encoder.SetIndent("", " ") + msg := fmt.Sprintf("%d violation(s) found:", len(violations)) + _, _ = color.New(color.FgRed, color.Bold).Fprintln(cmd.ErrWriter, msg) - for _, v := range violations { - if err := encoder.Encode(v); err != nil { - return fmt.Errorf("failed to output violation: %w", err) + if flagJSON { + err := jsonOutput(cmd.Writer, violations) + if err != nil { + return err } + } else { + textOutput(cmd.Writer, violations) } return nil }, } + +func textOutput(w io.Writer, violations []validator.Violation) { + shaColor := color.New(color.FgYellow, color.Bold) + for _, v := range violations { + _, _ = fmt.Fprintf(w, "%s %s\n", shaColor.Sprintf("%s", v.SHA), v.Header) + for _, o := range v.Outsiders { + _, _ = fmt.Fprintf(w, " - %s\n", o.File) + } + } +} + +func jsonOutput(writer io.Writer, violations []validator.Violation) error { + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + + if err := encoder.Encode(violations); err != nil { + return fmt.Errorf("failed to output violations: %w", err) + } + + return nil +} + +func configureLogger(writer io.Writer, verbose bool) { + opts := &slog.HandlerOptions{ + AddSource: false, + Level: slog.LevelInfo, + } + + if verbose { + opts.Level = slog.LevelDebug + opts.AddSource = true + } + + handler := slog.NewTextHandler(writer, opts) + + logger := slog.New(handler) + + slog.SetDefault(logger) +} diff --git a/go.mod b/go.mod index d3ec1a7..6762d91 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/thumbrise/commitlint-scope go 1.26.1 require ( + github.com/fatih/color v1.19.0 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/gobwas/glob v0.2.3 github.com/spf13/viper v1.21.0 diff --git a/go.sum b/go.sum index 3f6bdb8..5d4f527 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= diff --git a/main.go b/main.go index daba7e8..4290524 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "log/slog" "os" "github.com/thumbrise/commitlint-scope/cmd" @@ -12,22 +11,9 @@ import ( func main() { ctx := context.Background() - configureLogger() - if err := cmd.Root.Run(ctx, os.Args); err != nil { _, _ = fmt.Fprintf(os.Stderr, "error: %[1]v\n", err) os.Exit(1) } } - -func configureLogger() { - handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - AddSource: true, - Level: nil, - }) - - logger := slog.New(handler) - - slog.SetDefault(logger) -} diff --git a/pkg/validator/config.go b/pkg/validator/config.go index 6c42ba3..ef027c0 100644 --- a/pkg/validator/config.go +++ b/pkg/validator/config.go @@ -17,24 +17,24 @@ type Config struct { Patterns map[string][]string `mapstructure:"patterns"` } +var ErrConfigRead = errors.New("error reading config") + func LoadConfig() (Config, error) { v := viper.New() v.SetConfigName(configName) v.AddConfigPath(".") v.SetDefault("scopeRegex", regexp.MustCompile(`^[a-z]+(?:\((?P[^)]+)\))?!?:\s`)) + var cfg Config + if err := v.ReadInConfig(); err != nil { - if _, ok := errors.AsType[viper.ConfigFileNotFoundError](err); ok { - return Config{}, nil + if _, ok := errors.AsType[viper.ConfigFileNotFoundError](err); !ok { + return Config{}, fmt.Errorf("%w: %w", ErrConfigRead, err) } - - return Config{}, err } - var cfg Config - if err := v.Unmarshal(&cfg, regexDecode); err != nil { - return Config{}, err + return Config{}, fmt.Errorf("%w: %w", ErrConfigRead, err) } return cfg, nil diff --git a/pkg/validator/config_test.go b/pkg/validator/config_test.go new file mode 100644 index 0000000..b22656a --- /dev/null +++ b/pkg/validator/config_test.go @@ -0,0 +1,104 @@ +package validator_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thumbrise/commitlint-scope/pkg/validator" +) + +func TestLoadConfig(t *testing.T) { + defaultRegexStr := `^[a-z]+(?:\((?P[^)]+)\))?!?:\s` + + tests := []struct { + name string + yaml string + wantRegexNil bool + wantRegex string + wantPatterns map[string][]string + wantErr error + }{ + { + name: "no config file", + wantRegex: defaultRegexStr, + }, + { + name: "patterns with default regex", + yaml: `patterns: + api: + - api/* + core: + - core/** +`, + wantRegex: defaultRegexStr, + wantPatterns: map[string][]string{ + "api": {"api/*"}, + "core": {"core/**"}, + }, + }, + { + name: "custom scopeRegex only", + yaml: `scopeRegex: '^(feat|fix):' +`, + wantRegex: `^(feat|fix):`, + }, + { + name: "both patterns and custom scopeRegex", + yaml: `scopeRegex: '^(feat|fix):' +patterns: + api: + - api/* +`, + wantRegex: `^(feat|fix):`, + wantPatterns: map[string][]string{ + "api": {"api/*"}, + }, + }, + { + name: "malformed config", + yaml: "[[invalid", + wantErr: validator.ErrConfigRead, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + origDir, err := os.Getwd() + require.NoError(t, err) + + t.Cleanup(func() { + require.NoError(t, os.Chdir(origDir)) + }) + + require.NoError(t, os.Chdir(dir)) + + if tt.yaml != "" { + err = os.WriteFile(filepath.Join(dir, ".commitlint-scope.yaml"), []byte(tt.yaml), 0o644) + require.NoError(t, err) + } + + cfg, err := validator.LoadConfig() + if tt.wantErr != nil { + require.Error(t, err) + assert.ErrorIs(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + + if tt.wantRegexNil { + assert.Nil(t, cfg.ScopeRegex) + } else { + require.NotNil(t, cfg.ScopeRegex) + assert.Equal(t, tt.wantRegex, cfg.ScopeRegex.String()) + } + + assert.Equal(t, tt.wantPatterns, cfg.Patterns) + }) + } +} diff --git a/pkg/validator/scope_parser_test.go b/pkg/validator/scope_parser_test.go index 201d1f3..edc96ad 100644 --- a/pkg/validator/scope_parser_test.go +++ b/pkg/validator/scope_parser_test.go @@ -8,8 +8,6 @@ import ( ) func TestDefaultScopeParser_Parse(t *testing.T) { - compile := regexp.MustCompile - tests := []struct { name string regex *regexp.Regexp @@ -17,13 +15,14 @@ func TestDefaultScopeParser_Parse(t *testing.T) { want string }{ {name: "nil regex", regex: nil, message: "feat(api): add", want: ""}, - {name: "no match", regex: compile(`^feat\((?P[^)]+)\)`), message: "fix(api): bug", want: ""}, - {name: "match with scope", regex: compile(`^[a-z]+(?:\((?P[^)]+)\))?!?:\s`), message: "feat(api): add endpoint", want: "api"}, - {name: "no scope in message", regex: compile(`^[a-z]+(?:\((?P[^)]+)\))?!?:\s`), message: "chore: update deps", want: ""}, - {name: "regex without named group scope", regex: compile(`^[a-z]+(?:\(([^)]+)\))?!?:\s`), message: "feat(api): add", want: ""}, - {name: "scope with breaking change (!)", regex: compile(`^[a-z]+(?:\((?P[^)]+)\))?!?:\s`), message: "fix(auth)!: correct token", want: "auth"}, - {name: "multiple matches, returns first", regex: compile(`(?P[a-z]+)`), message: "api handler", want: "api"}, - {name: "empty scope in parentheses", regex: compile(`^feat\((?P[^)]*)\)`), message: "feat(): empty", want: ""}, + {name: "no match", regex: regexp.MustCompile(`^feat\((?P[^)]+)\)`), message: "fix(api): bug", want: ""}, + {name: "match with scope", regex: regexp.MustCompile(`^[a-z]+(?:\((?P[^)]+)\))?!?:\s`), message: "feat(api): add endpoint", want: "api"}, + {name: "no scope in message", regex: regexp.MustCompile(`^[a-z]+(?:\((?P[^)]+)\))?!?:\s`), message: "chore: update deps", want: ""}, + {name: "regex without named group scope", regex: regexp.MustCompile(`^[a-z]+(?:\(([^)]+)\))?!?:\s`), message: "feat(api): add", want: ""}, + {name: "scope with breaking change (!)", regex: regexp.MustCompile(`^[a-z]+(?:\((?P[^)]+)\))?!?:\s`), message: "fix(auth)!: correct token", want: "auth"}, + {name: "multiple matches, returns first", regex: regexp.MustCompile(`(?P[a-z]+)`), message: "api handler", want: "api"}, + {name: "empty scope in parentheses", regex: regexp.MustCompile(`^feat\((?P[^)]*)\)`), message: "feat(): empty", want: ""}, + {name: "composed scope", regex: regexp.MustCompile(`^[a-z]+(?:\((?P[^)]+)\))?!?:\s`), message: "style(services,frontend): Add linters and formatters, format whole frontend", want: "services,frontend"}, } for _, tt := range tests { diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go index 2320197..c2f8405 100644 --- a/pkg/validator/validator.go +++ b/pkg/validator/validator.go @@ -105,14 +105,14 @@ func (v *Validator) Validate(ctx context.Context, from, to string) ([]Violation, } if message == "" { - v.logger.Info("no message, skip", "sha", sha) + v.logger.Debug("no message, skip", "sha", sha) continue } scope := v.scopeParser.Parse(message) if scope == "" { - v.logger.Info("no scope, skip", "sha", sha, "message", message) + v.logger.Debug("no scope, skip", "sha", sha, "message", message) continue } @@ -123,7 +123,7 @@ func (v *Validator) Validate(ctx context.Context, from, to string) ([]Violation, } if len(files) == 0 { - v.logger.Info("no files changed, skip", "sha", sha) + v.logger.Debug("no files changed, skip", "sha", sha) continue }