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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
.idea
node_modules
commitlint-scope
build/*
6 changes: 6 additions & 0 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ tasks:
- gen
cmds:
- go tool mockery

build:
desc: Build
cmds:
- mkdir -p build
- go build -o build/
91 changes: 81 additions & 10 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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)
}
Expand All @@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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)
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
14 changes: 0 additions & 14 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package main
import (
"context"
"fmt"
"log/slog"
"os"

"github.com/thumbrise/commitlint-scope/cmd"
Expand All @@ -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)
}
14 changes: 7 additions & 7 deletions pkg/validator/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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<scope>[^)]+)\))?!?:\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
Expand Down
104 changes: 104 additions & 0 deletions pkg/validator/config_test.go
Original file line number Diff line number Diff line change
@@ -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<scope>[^)]+)\))?!?:\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)
})
}
}
17 changes: 8 additions & 9 deletions pkg/validator/scope_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,21 @@ import (
)

func TestDefaultScopeParser_Parse(t *testing.T) {
compile := regexp.MustCompile

tests := []struct {
name string
regex *regexp.Regexp
message string
want string
}{
{name: "nil regex", regex: nil, message: "feat(api): add", want: ""},
{name: "no match", regex: compile(`^feat\((?P<scope>[^)]+)\)`), message: "fix(api): bug", want: ""},
{name: "match with scope", regex: compile(`^[a-z]+(?:\((?P<scope>[^)]+)\))?!?:\s`), message: "feat(api): add endpoint", want: "api"},
{name: "no scope in message", regex: compile(`^[a-z]+(?:\((?P<scope>[^)]+)\))?!?:\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<scope>[^)]+)\))?!?:\s`), message: "fix(auth)!: correct token", want: "auth"},
{name: "multiple matches, returns first", regex: compile(`(?P<scope>[a-z]+)`), message: "api handler", want: "api"},
{name: "empty scope in parentheses", regex: compile(`^feat\((?P<scope>[^)]*)\)`), message: "feat(): empty", want: ""},
{name: "no match", regex: regexp.MustCompile(`^feat\((?P<scope>[^)]+)\)`), message: "fix(api): bug", want: ""},
{name: "match with scope", regex: regexp.MustCompile(`^[a-z]+(?:\((?P<scope>[^)]+)\))?!?:\s`), message: "feat(api): add endpoint", want: "api"},
{name: "no scope in message", regex: regexp.MustCompile(`^[a-z]+(?:\((?P<scope>[^)]+)\))?!?:\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<scope>[^)]+)\))?!?:\s`), message: "fix(auth)!: correct token", want: "auth"},
{name: "multiple matches, returns first", regex: regexp.MustCompile(`(?P<scope>[a-z]+)`), message: "api handler", want: "api"},
{name: "empty scope in parentheses", regex: regexp.MustCompile(`^feat\((?P<scope>[^)]*)\)`), message: "feat(): empty", want: ""},
{name: "composed scope", regex: regexp.MustCompile(`^[a-z]+(?:\((?P<scope>[^)]+)\))?!?:\s`), message: "style(services,frontend): Add linters and formatters, format whole frontend", want: "services,frontend"},
}

for _, tt := range tests {
Expand Down
6 changes: 3 additions & 3 deletions pkg/validator/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
Loading