diff --git a/.github/workflows/continuous-integration.yaml b/.github/workflows/continuous-integration.yaml index ad3a04a..b2bd33a 100644 --- a/.github/workflows/continuous-integration.yaml +++ b/.github/workflows/continuous-integration.yaml @@ -17,7 +17,15 @@ jobs: - name: install go uses: actions/setup-go@v4 with: - go-version: 1.23.1 + go-version: 1.24.3 + + - name: run tests + run: go test -v ./... + + - name: run linting + uses: golangci/golangci-lint-action@v6 + with: + version: latest - - name: pull request title validator + - name: pull request title validator [default] uses: ./ # kontrolplane/pull-request-title-validator@latest diff --git a/Dockerfile b/Dockerfile index 4f2bcd9..8d207ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,13 @@ -FROM golang:1.23.1 AS build +FROM golang:1.24.3 AS build + WORKDIR /action + COPY . . + RUN CGO_ENABLED=0 go build -o pull-request-title-validator -FROM alpine:latest +FROM alpine:latest + COPY --from=build /action/pull-request-title-validator /pull-request-title-validator -ENTRYPOINT ["/pull-request-title-validator"] \ No newline at end of file + +ENTRYPOINT ["/pull-request-title-validator"] diff --git a/README.md b/README.md index b03a057..0631c6e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ jobs: runs-on: ubuntu-latest steps: - name: validate pull request title - uses: kontrolplane/pull-request-title-validator@v1.4.2 + uses: kontrolplane/pull-request-title-validator@v1.5.0 ``` ### Custom types @@ -62,7 +62,7 @@ jobs: runs-on: ubuntu-latest steps: - name: validate pull request title - uses: kontrolplane/pull-request-title-validator@v1.4.2 + uses: kontrolplane/pull-request-title-validator@v1.5.0 with: types: "fix,feat,chore" ``` @@ -90,7 +90,7 @@ jobs: runs-on: ubuntu-latest steps: - name: validate pull request title - uses: kontrolplane/pull-request-title-validator@v1.4.2 + uses: kontrolplane/pull-request-title-validator@v1.5.0 with: scopes: "api,lang,parser,package/.+" ``` diff --git a/go.mod b/go.mod index 0c0880b..ecb40fd 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/kontrolplane/pull-request-title-validator -go 1.23.1 +go 1.24.3 require github.com/caarlos0/env v3.5.0+incompatible diff --git a/main.go b/main.go index 235277f..aca6e3e 100644 --- a/main.go +++ b/main.go @@ -3,19 +3,23 @@ package main import ( "encoding/json" "fmt" + "log/slog" "os" "regexp" "strings" - "log/slog" - "github.com/caarlos0/env" ) -var desiredFormat string = "(optional: ): " -var defaultConventionTypes []string = []string{"fix", "feat", "chore", "docs", "build", "ci", "refactor", "perf", "test"} +const ( + desiredFormat = "(optional: ): " +) -type config struct { +var defaultConventionTypes = []string{ + "fix", "feat", "chore", "docs", "build", "ci", "refactor", "perf", "test", +} + +type Config struct { GithubEventName string `env:"GITHUB_EVENT_NAME"` GithubEventPath string `env:"GITHUB_EVENT_PATH"` Types string `env:"INPUT_TYPES"` @@ -30,100 +34,137 @@ type Event struct { PullRequest PullRequest `json:"pull_request"` } -// The pull-request-title-validator function mankes sure that for each pull request created the -// title of the pull request adheres to a desired structure, in this case convention commit style. +type TitleComponents struct { + Type string + Scope string + Message string +} + +type Validator struct { + logger *slog.Logger + config Config +} + func main() { + logger := setupLogger() - var cfg config - if err := env.Parse(&cfg); err != nil { - fmt.Printf("unable to parse the environment variables: %v", err) + cfg, err := loadConfig() + if err != nil { + logger.Error("unable to parse environment variables", slog.Any("error", err)) os.Exit(1) } + validator := &Validator{ + logger: logger, + config: cfg, + } + + if err := validator.run(); err != nil { + os.Exit(1) + } +} + +func setupLogger() *slog.Logger { logHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ AddSource: false, Level: slog.LevelInfo, }) - logger := slog.New(logHandler) + return slog.New(logHandler) +} - logger.Info("starting pull-request-title-validator", slog.String("event", cfg.GithubEventName)) +func loadConfig() (Config, error) { + var cfg Config + err := env.Parse(&cfg) + return cfg, err +} - if cfg.GithubEventName != "pull_request" && cfg.GithubEventName != "pull_request_target" { - logger.Error("invalid event type", slog.String("event", cfg.GithubEventName)) - os.Exit(1) - } +func (v *Validator) run() error { + v.logger.Info("starting pull-request-title-validator", + slog.String("event", v.config.GithubEventName)) - title := fetchTitle(logger, cfg.GithubEventPath) - titleType, titleScope, titleMessage := splitTitle(logger, title) + if err := v.validateEventType(); err != nil { + return err + } - parsedTypes := parseTypes(logger, cfg.Types, defaultConventionTypes) - parsedScopes := parseScopes(logger, cfg.Scopes) + title, err := v.fetchTitle() + if err != nil { + return err + } - if err := checkAgainstConventionTypes(logger, titleType, parsedTypes); err != nil { - logger.Error("error while checking the type against the allowed types", - slog.String("event name", cfg.GithubEventName), - slog.String("event path", cfg.GithubEventPath), - slog.Any("convention types", parsedTypes), - ) - os.Exit(1) + components, err := v.parseTitle(title) + if err != nil { + return err } - if err := checkAgainstScopes(logger, titleScope, parsedScopes); err != nil && len(parsedScopes) >= 1 { - logger.Error("error while checking the scope against the allowed scopes", slog.Any("error", err)) - os.Exit(1) + if err := v.validateTitle(components); err != nil { + return err } - logger.Info("commit title validated successfully", - slog.String("type", titleType), - slog.String("scope", titleScope), - slog.String("message", titleMessage), + v.logger.Info("commit title validated successfully", + slog.String("type", components.Type), + slog.String("scope", components.Scope), + slog.String("message", components.Message), ) - logger.Info("the commit message adheres to the configured standard") + v.logger.Info("the commit message adheres to the configured standard") + + return nil } -func fetchTitle(logger *slog.Logger, githubEventPath string) string { - var event Event - var eventData []byte - var err error +func (v *Validator) validateEventType() error { + if v.config.GithubEventName != "pull_request" && v.config.GithubEventName != "pull_request_target" { + v.logger.Error("invalid event type", slog.String("event", v.config.GithubEventName)) + return fmt.Errorf("invalid event type: %s", v.config.GithubEventName) + } + return nil +} - if eventData, err = os.ReadFile(githubEventPath); err != nil { - logger.Error("Problem reading the event JSON file", slog.String("path", githubEventPath), slog.Any("error", err)) - os.Exit(1) +func (v *Validator) fetchTitle() (string, error) { + eventData, err := os.ReadFile(v.config.GithubEventPath) + if err != nil { + v.logger.Error("problem reading the event JSON file", + slog.String("path", v.config.GithubEventPath), + slog.Any("error", err)) + return "", err } - if err = json.Unmarshal(eventData, &event); err != nil { - logger.Error("Failed to unmarshal JSON", slog.Any("error", err)) - os.Exit(1) + var event Event + if err := json.Unmarshal(eventData, &event); err != nil { + v.logger.Error("failed to unmarshal JSON", slog.Any("error", err)) + return "", err } - return event.PullRequest.Title + return event.PullRequest.Title, nil } -func splitTitle(logger *slog.Logger, title string) (titleType string, titleScope string, titleMessage string) { +func (v *Validator) parseTitle(title string) (*TitleComponents, error) { // Split title into prefix (type/scope) and message parts using colon as separator prefix, message, found := strings.Cut(title, ":") if !found { - logger.Error("Title must include a message after the colon", + v.logger.Error("title must include a message after the colon", slog.String("desired format", desiredFormat), slog.String("title", title)) - os.Exit(1) + return nil, fmt.Errorf("title missing colon separator") } // Clean up the message part - titleMessage = strings.TrimSpace(message) + titleMessage := strings.TrimSpace(message) // Extract type and scope from the prefix - titleType, titleScope = extractTypeAndScope(prefix) + titleType, titleScope := extractTypeAndScope(prefix) // Validate that we found a type if titleType == "" { - logger.Error("Title must include a type", + v.logger.Error("title must include a type", slog.String("desired format", desiredFormat), slog.String("title", title)) - os.Exit(1) + return nil, fmt.Errorf("title missing type") } - return titleType, titleScope, titleMessage + return &TitleComponents{ + Type: titleType, + Scope: titleScope, + Message: titleMessage, + }, nil } func extractTypeAndScope(prefix string) (titleType string, titleScope string) { @@ -131,35 +172,58 @@ func extractTypeAndScope(prefix string) (titleType string, titleScope string) { // Check if prefix contains a scope in parentheses if strings.Contains(prefix, "(") && strings.Contains(prefix, ")") { - // Extract scope using regex scopeRegex := regexp.MustCompile(`\(([^)]+)\)`) - // if matches := scopeRegex.FindStringSubmatch(prefix); len(matches) > 1 { titleScope = matches[1] titleType = strings.TrimSpace(strings.Split(prefix, "(")[0]) - return + return titleType, titleScope } } // If no scope found or invalid format, use entire prefix as type titleType = prefix - return + return titleType, titleScope +} + +func (v *Validator) validateTitle(components *TitleComponents) error { + parsedTypes := v.parseTypes() + parsedScopes := v.parseScopes() + + if err := v.validateType(components.Type, parsedTypes); err != nil { + v.logger.Error("error while checking the type against the allowed types", + slog.String("event name", v.config.GithubEventName), + slog.String("event path", v.config.GithubEventPath), + slog.Any("convention types", parsedTypes), + ) + return err + } + + if err := v.validateScope(components.Scope, parsedScopes); err != nil && len(parsedScopes) >= 1 { + v.logger.Error("error while checking the scope against the allowed scopes", + slog.Any("error", err)) + return err + } + + return nil } -func checkAgainstConventionTypes(logger *slog.Logger, titleType string, conventionTypes []string) error { - for _, conventionType := range conventionTypes { - if titleType == conventionType { +func (v *Validator) validateType(titleType string, allowedTypes []string) error { + for _, allowedType := range allowedTypes { + if titleType == allowedType { return nil } } - logger.Error("Type not allowed by the convention", slog.String("type", titleType), slog.Any("allowedTypes", conventionTypes)) + + v.logger.Error("type not allowed by the convention", + slog.String("type", titleType), + slog.Any("allowedTypes", allowedTypes)) return fmt.Errorf("type '%s' is not allowed", titleType) } -func checkAgainstScopes(logger *slog.Logger, titleScope string, scopes []string) error { - for _, scope := range scopes { +func (v *Validator) validateScope(titleScope string, allowedScopes []string) error { + for _, scope := range allowedScopes { if regexp.MustCompile("(?i)" + scope + "$").MatchString(titleScope) { return nil } @@ -168,30 +232,28 @@ func checkAgainstScopes(logger *slog.Logger, titleScope string, scopes []string) return fmt.Errorf("scope '%s' is not allowed", titleScope) } -func parseTypes(logger *slog.Logger, input string, fallback []string) []string { - if input == "" { - logger.Warn("No custom list of commit types passed, using fallback.") - return fallback - } - - types := strings.Split(input, ",") - for i := range types { - types[i] = strings.TrimSpace(types[i]) +func (v *Validator) parseTypes() []string { + if v.config.Types == "" { + v.logger.Warn("no custom list of commit types passed, using fallback") + return defaultConventionTypes } - return types + return parseCommaSeparatedList(v.config.Types) } -func parseScopes(logger *slog.Logger, input string) []string { - if input == "" { - logger.Warn("No custom list of commit scopes passed, using fallback.") +func (v *Validator) parseScopes() []string { + if v.config.Scopes == "" { + v.logger.Warn("no custom list of commit scopes passed, using fallback") return []string{} } - scopes := strings.Split(input, ",") - for i := range scopes { - scopes[i] = strings.TrimSpace(scopes[i]) - } + return parseCommaSeparatedList(v.config.Scopes) +} - return scopes +func parseCommaSeparatedList(input string) []string { + items := strings.Split(input, ",") + for i := range items { + items[i] = strings.TrimSpace(items[i]) + } + return items } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..dd8464a --- /dev/null +++ b/main_test.go @@ -0,0 +1,306 @@ +// main_test.go +package main + +import ( + "log/slog" + "os" + "testing" +) + +func TestExtractTypeAndScope(t *testing.T) { + tests := []struct { + name string + prefix string + expectedType string + expectedScope string + }{ + { + name: "type with scope", + prefix: "feat(api)", + expectedType: "feat", + expectedScope: "api", + }, + { + name: "type without scope", + prefix: "fix", + expectedType: "fix", + expectedScope: "", + }, + { + name: "type with complex scope", + prefix: "refactor(package/utils)", + expectedType: "refactor", + expectedScope: "package/utils", + }, + { + name: "type with scope containing special chars", + prefix: "feat(api/v2)", + expectedType: "feat", + expectedScope: "api/v2", + }, + { + name: "malformed scope - missing closing", + prefix: "feat(api", + expectedType: "feat(api", + expectedScope: "", + }, + { + name: "empty prefix", + prefix: "", + expectedType: "", + expectedScope: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotType, gotScope := extractTypeAndScope(tt.prefix) + if gotType != tt.expectedType { + t.Errorf("extractTypeAndScope() type = %v, want %v", gotType, tt.expectedType) + } + if gotScope != tt.expectedScope { + t.Errorf("extractTypeAndScope() scope = %v, want %v", gotScope, tt.expectedScope) + } + }) + } +} + +func TestValidateType(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + validator := &Validator{logger: logger} + + tests := []struct { + name string + titleType string + allowedTypes []string + shouldPass bool + }{ + { + name: "valid type", + titleType: "feat", + allowedTypes: []string{"feat", "fix", "chore"}, + shouldPass: true, + }, + { + name: "invalid type", + titleType: "invalid", + allowedTypes: []string{"feat", "fix", "chore"}, + shouldPass: false, + }, + { + name: "empty type", + titleType: "", + allowedTypes: []string{"feat", "fix", "chore"}, + shouldPass: false, + }, + { + name: "case sensitive", + titleType: "FEAT", + allowedTypes: []string{"feat", "fix", "chore"}, + shouldPass: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.validateType(tt.titleType, tt.allowedTypes) + if tt.shouldPass && err != nil { + t.Errorf("validateType() should pass but got error: %v", err) + } + if !tt.shouldPass && err == nil { + t.Errorf("validateType() should fail but passed") + } + }) + } +} + +func TestValidateScope(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + validator := &Validator{logger: logger} + + tests := []struct { + name string + titleScope string + allowedScopes []string + shouldPass bool + }{ + { + name: "valid exact scope", + titleScope: "api", + allowedScopes: []string{"api", "ui", "core"}, + shouldPass: true, + }, + { + name: "invalid scope", + titleScope: "database", + allowedScopes: []string{"api", "ui", "core"}, + shouldPass: false, + }, + { + name: "regex pattern match", + titleScope: "package/utils", + allowedScopes: []string{"package/.+", "api/v[0-9]+"}, + shouldPass: true, + }, + { + name: "regex pattern no match", + titleScope: "invalid/path", + allowedScopes: []string{"package/.+", "api/v[0-9]+"}, + shouldPass: false, + }, + { + name: "case insensitive match", + titleScope: "API", + allowedScopes: []string{"api", "ui", "core"}, + shouldPass: true, + }, + { + name: "empty scope with allowed scopes", + titleScope: "", + allowedScopes: []string{"api", "ui", "core"}, + shouldPass: false, + }, + { + name: "empty scope with no restrictions", + titleScope: "", + allowedScopes: []string{}, + shouldPass: false, // Empty scope should not match empty pattern + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.validateScope(tt.titleScope, tt.allowedScopes) + if tt.shouldPass && err != nil { + t.Errorf("validateScope() should pass but got error: %v", err) + } + if !tt.shouldPass && err == nil { + t.Errorf("validateScope() should fail but passed") + } + }) + } +} + +func TestParseCommaSeparatedList(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "normal list", + input: "feat,fix,chore", + expected: []string{"feat", "fix", "chore"}, + }, + { + name: "list with spaces", + input: " feat , fix , chore ", + expected: []string{"feat", "fix", "chore"}, + }, + { + name: "single item", + input: "feat", + expected: []string{"feat"}, + }, + { + name: "empty string", + input: "", + expected: []string{""}, + }, + { + name: "trailing comma", + input: "feat,fix,", + expected: []string{"feat", "fix", ""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseCommaSeparatedList(tt.input) + if len(result) != len(tt.expected) { + t.Errorf("parseCommaSeparatedList() length = %v, want %v", len(result), len(tt.expected)) + return + } + for i, v := range result { + if v != tt.expected[i] { + t.Errorf("parseCommaSeparatedList()[%d] = %v, want %v", i, v, tt.expected[i]) + } + } + }) + } +} + +func TestParseTitle(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + validator := &Validator{logger: logger} + + tests := []struct { + name string + title string + expectedType string + expectedScope string + expectedMsg string + shouldPass bool + }{ + { + name: "valid title with scope", + title: "feat(api): add new endpoint", + expectedType: "feat", + expectedScope: "api", + expectedMsg: "add new endpoint", + shouldPass: true, + }, + { + name: "valid title without scope", + title: "fix: resolve memory leak", + expectedType: "fix", + expectedScope: "", + expectedMsg: "resolve memory leak", + shouldPass: true, + }, + { + name: "invalid title - no colon", + title: "feat add new feature", + shouldPass: false, + }, + { + name: "invalid title - no type", + title: ": add new feature", + shouldPass: false, + }, + { + name: "title with complex scope", + title: "refactor(package/utils): optimize helper functions", + expectedType: "refactor", + expectedScope: "package/utils", + expectedMsg: "optimize helper functions", + shouldPass: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + components, err := validator.parseTitle(tt.title) + + if tt.shouldPass { + if err != nil { + t.Errorf("parseTitle() should pass but got error: %v", err) + return + } + if components.Type != tt.expectedType { + t.Errorf("parseTitle() type = %v, want %v", components.Type, tt.expectedType) + } + if components.Scope != tt.expectedScope { + t.Errorf("parseTitle() scope = %v, want %v", components.Scope, tt.expectedScope) + } + if components.Message != tt.expectedMsg { + t.Errorf("parseTitle() message = %v, want %v", components.Message, tt.expectedMsg) + } + } else { + if err == nil { + t.Errorf("parseTitle() should fail but passed") + } + } + }) + } +}