diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..ef94d45 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,25 @@ +reviews: + profile: chill + request_changes_workflow: true + high_level_summary: true + review_status: true + commit_status: true + collapse_walkthrough: true + auto_assign_reviewers: true + in_progress_fortune: false + poem: false + auto_review: + enabled: true + auto_incremental_review: true + ignore_title_keywords: + - "WIP" + - "DO NOT MERGE" + drafts: false + base_branches: + - "main" + pre_merge_checks: + title: + mode: error + requirements: 'Title should be concise and descriptive, ideally under 72 characters. Use imperative mood (e.g., "Add database for snippets").' +chat: + auto_reply: true diff --git a/.lefthook.yml b/.lefthook.yml index b58fcdc..406943a 100644 --- a/.lefthook.yml +++ b/.lefthook.yml @@ -37,16 +37,15 @@ pre-commit: - go.sum stage_fixed: true - name: lint & format - run: golangci-lint run ./... glob: "*.go" - stage_fixed: true - - name: format markdown - glob: "*md" - run: go tool markdownfmt -w . - stage_fixed: true - - name: check release config - glob: ".goreleaser.yaml" - run: go tool goreleaser check + group: + parallel: false + jobs: + - name: format + run: golangci-lint fmt ./... + stage_fixed: true + - name: lint + run: golangci-lint run ./... post-checkout: parallel: false diff --git a/cmd/commands.go b/cmd/commands.go index de27b3f..a67cd85 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -6,4 +6,5 @@ import ( var commands = []*cli.Command{ version(), + commit(), } diff --git a/cmd/commit.go b/cmd/commit.go new file mode 100644 index 0000000..6cb805d --- /dev/null +++ b/cmd/commit.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + + "github.com/urfave/cli/v3" + + "github.com/isokolovskii/commitizen/internal/conventional" +) + +var ErrInvalidFlag = errors.New("invalid flag") + +func commit() *cli.Command { + commitData := conventional.Commit{} + + return &cli.Command{ + Name: "commit", + Usage: "Create Conventional Commit", + Flags: flags(&commitData), + Action: func(_ context.Context, _ *cli.Command) error { + message, err := conventional.BuildCommitMessage(&commitData) + if err != nil { + return fmt.Errorf("error building commit: %w", err) + } + + _, err = fmt.Println(message) + if err != nil { + return fmt.Errorf("error printing built commit message: %w", err) + } + + return nil + }, + } +} + +func flags(commitData *conventional.Commit) []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "type", + OnlyOnce: true, + Destination: &commitData.Type, + Required: true, + Usage: "Type of change (e.g., feat, fix, docs)", + }, + &cli.StringFlag{ + Name: "scope", + OnlyOnce: true, + Destination: &commitData.Scope, + Required: false, + Usage: "Optional context for the change (e.g., api, cli)", + }, + &cli.StringFlag{ + Name: "title", + OnlyOnce: true, + Destination: &commitData.Title, + Required: true, + Usage: "Short description of changes", + }, + &cli.StringFlag{ + Name: "body", + OnlyOnce: true, + Destination: &commitData.Body, + Required: false, + Usage: "Optional longer description of the change", + }, + &cli.StringFlag{ + Name: "breaking", + OnlyOnce: true, + Destination: &commitData.BreakingChange, + Required: false, + Usage: "Optional description of breaking changes introduced with commit", + }, + &cli.StringFlag{ + Name: "issue", + OnlyOnce: true, + Destination: &commitData.Issue, + Required: false, + Usage: "Optional issue number", + }, + } +} diff --git a/internal/conventional/commit.go b/internal/conventional/commit.go new file mode 100644 index 0000000..06460a3 --- /dev/null +++ b/internal/conventional/commit.go @@ -0,0 +1,20 @@ +package conventional + +type ( + // Commit represents the components of a Conventional Commit message. + // See https://www.conventionalcommits.org/ for the specification. + Commit struct { + // Type describes the kind of change (e.g., "feat", "fix", "docs"). + Type string + // Scope is an optional context for the change (e.g., "api", "cli"). + Scope string + // Title is a short description of the change. + Title string + // Body is an optional longer description. + Body string + // Breaking change description. + BreakingChange string + // Issue number. + Issue string + } +) diff --git a/internal/conventional/message.go b/internal/conventional/message.go new file mode 100644 index 0000000..c284068 --- /dev/null +++ b/internal/conventional/message.go @@ -0,0 +1,69 @@ +package conventional + +import ( + "errors" + "fmt" +) + +const ( + newLine = "\n" +) + +// ErrRequiredPartNotPreset - error used for missing required conventional commit fields. +var ErrRequiredPartNotPreset = errors.New("required part not present") + +// BuildCommitMessage - builds conventional commit message. +func BuildCommitMessage(commit *Commit) (string, error) { + if commit.Type == "" { + return "", fmt.Errorf("%w: type", ErrRequiredPartNotPreset) + } + + if commit.Title == "" { + return "", fmt.Errorf("%w: title", ErrRequiredPartNotPreset) + } + + header := buildHeader(commit) + footer := buildFooter(commit) + + return header + newLine + footer, nil +} + +func buildHeader(commit *Commit) string { + header := commit.Type + + if commit.Scope != "" { + header = fmt.Sprintf("%s(%s)", header, commit.Scope) + } + + if commit.BreakingChange != "" { + header += "!" + } + + header += ": " + commit.Title + + return header +} + +func buildFooter(commit *Commit) string { + footer := "" + + if commit.BreakingChange != "" { + footer += newLine + "BREAKING CHANGE: " + commit.BreakingChange + } + + if commit.Issue != "" { + footer += newLine + "Issue: " + commit.Issue + } + + if commit.Body != "" { + if footer != "" { + footer = newLine + commit.Body + newLine + footer + newLine + } else { + footer = newLine + commit.Body + newLine + } + } else if footer != "" { + footer += newLine + } + + return footer +} diff --git a/internal/conventional/message_test.go b/internal/conventional/message_test.go new file mode 100644 index 0000000..4c8fbe9 --- /dev/null +++ b/internal/conventional/message_test.go @@ -0,0 +1,110 @@ +package conventional + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/assert" +) + +type ( + testCases struct { + commitData *Commit + err error + result string + } +) + +var buildCommitMessageTestCases = []testCases{ + { + result: "feat(api): some changes\n\nChanges description\n", + commitData: &Commit{ + Title: "some changes", + Type: "feat", + Scope: "api", + Body: "Changes description", + }, + err: nil, + }, + { + result: "feat(api): some changes\n", + commitData: &Commit{ + Title: "some changes", + Type: "feat", + Scope: "api", + }, + err: nil, + }, + { + result: "feat: some changes\n\nChanges description\n", + commitData: &Commit{ + Title: "some changes", + Type: "feat", + Body: "Changes description", + }, + err: nil, + }, + { + result: "feat(api): some changes\n\nChanges description\n\nIssue: TEST-1\n", + commitData: &Commit{ + Title: "some changes", + Type: "feat", + Scope: "api", + Body: "Changes description", + Issue: "TEST-1", + }, + err: nil, + }, + { + result: "feat(api)!: some changes\n\nChanges description\n\nBREAKING CHANGE: Something breaking\nIssue: TEST-1\n", + commitData: &Commit{ + Title: "some changes", + Type: "feat", + Scope: "api", + Body: "Changes description", + Issue: "TEST-1", + BreakingChange: "Something breaking", + }, + err: nil, + }, + { + result: "feat!: some changes\n\nChanges description\n\nBREAKING CHANGE: Something breaking\n", + commitData: &Commit{ + Title: "some changes", + Type: "feat", + Body: "Changes description", + BreakingChange: "Something breaking", + }, + err: nil, + }, + { + result: "", + commitData: &Commit{ + Type: "feat", + Body: "Changes description", + }, + err: ErrRequiredPartNotPreset, + }, + { + result: "", + commitData: &Commit{ + Title: "some changes", + Body: "Changes description", + }, + err: ErrRequiredPartNotPreset, + }, +} + +func TestBuildCommitMessage(t *testing.T) { + t.Parallel() + for i, tt := range buildCommitMessageTestCases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + t.Parallel() + + message, err := BuildCommitMessage(tt.commitData) + + assert.Equal(t, tt.result, message) + assert.ErrorIs(t, err, tt.err) + }) + } +}