diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 88a1810..ca77893 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -46,4 +46,4 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v3 with: - version: v1.54.1 + version: v1.61.0 diff --git a/.gitignore b/.gitignore index eea1cb3..6ad919d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ coverage.xml # Gorelease output dist/ + +# VSCode configurations. +.vscode/launch.json \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index d6f5734..f8da5f9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,6 @@ --- run: - go: '1.21.3' + go: '1.23.2' linters: disable-all: false @@ -14,6 +14,7 @@ linters: - bodyclose - containedctx - contextcheck + - copyloopvar - cyclop - decorder - depguard @@ -21,14 +22,13 @@ linters: - dupl - dupword - durationcheck + - err113 - errcheck - errchkjson - errname - errorlint - - execinquery - exhaustive - exhaustruct - - exportloopref - forbidigo - forcetypeassert - funlen @@ -42,12 +42,10 @@ linters: - gocyclo - godot - godox - - goerr113 - gofmt - gofumpt - goheader - goimports - - gomnd - gomoddirectives - gomodguard - goprintffuncname @@ -66,6 +64,7 @@ linters: - makezero - mirror - misspell + - mnd - musttag - nakedret - nestif diff --git a/README.md b/README.md index c29b35e..60d8d15 100644 --- a/README.md +++ b/README.md @@ -50,38 +50,62 @@ Usage: conflictless [flags] The commands are: check Checks that change-files are valid + create Creates a new change-file generate Generates a version entry to changelog file help Prints this help message Use "conflictless help " for more information about that topic. ``` -`conflictless help generate` +`conflictless help check` ``` txt -Usage: conflictless generate [flags] +Usage: conflictless check [flags] The flags are: - -b, --bump - Bump version patch/minor/major (default: minor) - -c, --changelog - Changelog file (default: CHANGELOG.md) -d, --dir Directory where to look for change-files (default: changes) - -s, --skip-version-links - Skip version links in changelog file (default: false) ``` -`conflictless help check` +`conflictless help create` ```txt -Usage: conflictless check [flags] +Usage: conflictless create [flags] + +The flags are: + + -d, --dir + Directory where the change-file should be created (default: changes) + -f, --format + File format and extension yml/yaml/json for the change-file (default: yml) + -t, --types + Types of changes you want for the change-file (default: changed) + + Multiple values can be given by separating values with commas. + Example: '--format added,changed,deprecated,removed,fixed,security'. + -n, --name + Name for the change-file without file extension + + If this flag is not given the name will be derived from the name of the + current git branch you're on. +``` + +`conflictless help generate` + +``` txt +Usage: conflictless generate [flags] The flags are: + -b, --bump + Bump version patch/minor/major (default: minor) + -c, --changelog + Changelog file (default: CHANGELOG.md) -d, --dir Directory where to look for change-files (default: changes) + -s, --skip-version-links + Skip version links in changelog file (default: false) ``` ## Suggested workflow diff --git a/changes/2-create-a-change-file.yml b/changes/2-create-a-change-file.yml new file mode 100644 index 0000000..6f432ba --- /dev/null +++ b/changes/2-create-a-change-file.yml @@ -0,0 +1,3 @@ +--- +added: + - "New command 'create'" diff --git a/go.mod b/go.mod index 8d9e6cf..3975ba0 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ypjama/conflictless-keepachangelog -go 1.21.3 +go 1.23.2 require ( github.com/stretchr/testify v1.3.0 @@ -13,4 +13,5 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + golang.org/x/text v0.19.0 // indirect ) diff --git a/go.sum b/go.sum index f7cd70a..ab14ee0 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/pkg/conflictless/bump_test.go b/internal/pkg/conflictless/bump_test.go index 7e98a8b..42a91ae 100644 --- a/internal/pkg/conflictless/bump_test.go +++ b/internal/pkg/conflictless/bump_test.go @@ -20,9 +20,6 @@ func TestInitialVersion(t *testing.T) { {"major", conflictless.BumpMajor, "1.0.0"}, {"unknown", conflictless.Bump(123), "0.1.0"}, } { - // Reinitialise testCase for parallel testing. - testCase := testCase - t.Run(testCase.description, func(t *testing.T) { t.Parallel() diff --git a/internal/pkg/conflictless/changelog.go b/internal/pkg/conflictless/changelog.go index d2dc7e6..fd3bf89 100644 --- a/internal/pkg/conflictless/changelog.go +++ b/internal/pkg/conflictless/changelog.go @@ -11,7 +11,6 @@ import ( const ( minSemverParts = 3 - writeFileMode = 0o644 ) type Changelog struct { diff --git a/internal/pkg/conflictless/cli.go b/internal/pkg/conflictless/cli.go index 2f42ff6..b556eb6 100644 --- a/internal/pkg/conflictless/cli.go +++ b/internal/pkg/conflictless/cli.go @@ -8,32 +8,43 @@ import ( ) const ( - argIdxCommand = 1 - argIdxHelpTopic = 2 - exitCodeSuccess = 0 - exitCodeGeneralError = 1 - exitCodeMisuseError = 2 - commandCheck = "check" - commandGen = "generate" - commandHelp = "help" - defaultBump = BumpMinor - minArguments = 2 + argIdxCommand = 1 + argIdxHelpTopic = 2 + commandCheck = "check" + commandCreate = "create" + commandGen = "generate" + commandHelp = "help" + defaultBump = BumpMinor + defaultChangeFileFormat = "yml" + defaultChangeTypesCSV = "changed" + defaultDirectory = "changes" + exitCodeGeneralError = 1 + exitCodeMisuseError = 2 + exitCodeSuccess = 0 + minArguments = 2 ) func CLI() { cfg := Config{ Flags: FlagCollection{ Bump: new(string), + ChangeFileFormat: new(string), + ChangeFileName: new(string), ChangelogFile: new(string), + ChangeTypesCsv: new(string), Command: "", Directory: new(string), SkipVersionLinks: false, }, Bump: defaultBump, + Changelog: nil, ChangelogFile: "CHANGELOG.md", + ChangeFile: "", + ChangeTypesCsv: defaultChangeTypesCSV, + ChangeFileFormat: defaultChangeFileFormat, + Directory: defaultDirectory, RepositoryConfigFile: ".git/config", - Changelog: nil, - Directory: "changes", + RepositoryHeadFile: ".git/HEAD", } parseCLIFlags(&cfg) @@ -44,6 +55,8 @@ func CLI() { switch cfg.Flags.Command { case commandCheck: Check(&cfg) + case commandCreate: + Create(&cfg) case commandGen: Generate(&cfg) case commandHelp: @@ -95,6 +108,14 @@ func parseCLIFlags(cfg *Config) { cmd.Usage = usageCheckOnError defineDirFlags(cfg, cmd) + case commandCreate: + cmd = flag.NewFlagSet(commandCreate, flag.ExitOnError) + cmd.Usage = usageCreateOnError + + defineFormatFlags(cfg, cmd) + defineCreateTypeFlags(cfg, cmd) + defineDirFlags(cfg, cmd) + defineChangeFileNameFlags(cfg, cmd) } if cmd != nil { @@ -126,6 +147,21 @@ func defineDirFlags(cfg *Config, fs *flag.FlagSet) { fs.StringVar(cfg.Flags.Directory, "d", defaultDir, "") } +func defineFormatFlags(cfg *Config, fs *flag.FlagSet) { + fs.StringVar(cfg.Flags.ChangeFileFormat, "format", defaultChangeFileFormat, "") + fs.StringVar(cfg.Flags.ChangeFileFormat, "f", defaultChangeFileFormat, "") +} + +func defineCreateTypeFlags(cfg *Config, fs *flag.FlagSet) { + fs.StringVar(cfg.Flags.ChangeTypesCsv, "types", defaultChangeTypesCSV, "") + fs.StringVar(cfg.Flags.ChangeTypesCsv, "t", defaultChangeTypesCSV, "") +} + +func defineChangeFileNameFlags(cfg *Config, fs *flag.FlagSet) { + fs.StringVar(cfg.Flags.ChangeFileName, "name", "", "") + fs.StringVar(cfg.Flags.ChangeFileName, "n", "", "") +} + func defineSkipFlags(cfg *Config, fs *flag.FlagSet) { fs.BoolVar(&cfg.Flags.SkipVersionLinks, "skip-version-links", false, "") fs.BoolVar(&cfg.Flags.SkipVersionLinks, "s", false, "") diff --git a/internal/pkg/conflictless/cli_test.go b/internal/pkg/conflictless/cli_test.go index f4b877f..6898a09 100644 --- a/internal/pkg/conflictless/cli_test.go +++ b/internal/pkg/conflictless/cli_test.go @@ -111,12 +111,10 @@ func TestCLIHelp(t *testing.T) { }{ {"help", []string{"help"}, false}, {"help check", []string{"help", "check"}, false}, + {"help create", []string{"help", "create"}, false}, {"help generate", []string{"help", "generate"}, false}, {"help unknown", []string{"help", "unknown"}, true}, } { - // Reinitialise testCase for parallel testing. - testCase := testCase - t.Run(testCase.description, func(t *testing.T) { t.Parallel() @@ -213,3 +211,60 @@ func TestCLIGenerateWithInvalidFlags(t *testing.T) { assert.Empty(t, string(stdoutData)) assert.NotEmpty(t, string(stderrData)) } + +func TestCLICreateWithInvalidFlags(t *testing.T) { + t.Parallel() + + if os.Getenv("TEST_CLI_CREATE_INVALID_FLAGS") != "" { + conflictless.CLI() + + return + } + + stdoutFile := createTempFile(t, os.TempDir(), "test-cli-create-with-invalid-flags-stdout") + defer os.Remove(stdoutFile.Name()) + + stderrFile := createTempFile(t, os.TempDir(), "test-cli-create-with-invalid-flags-stderr") + defer os.Remove(stderrFile.Name()) + + //nolint:gosec // this is a test package so G204 doesn't really matter here. + cmd := exec.Command( + os.Args[0], + "-test.run=^TestCLICreateWithInvalidFlags$", + "create", + "--dir", + "jaybird", + "--format", + "xml", + "--types", + "added,changed", + "--name", + "harmless-catfish", + ) + + cmd.Stdout = stdoutFile + cmd.Stderr = stderrFile + + cmd.Env = append(os.Environ(), "TEST_CLI_CREATE_INVALID_FLAGS=1") + err := cmd.Run() + + assert.Error(t, err) + assert.IsType(t, new(exec.ExitError), err) + + exitErr := new(*exec.ExitError) + errors.As(err, exitErr) + + expectedCode := 2 + exitCode := (*exitErr).ExitCode() + + assert.Equal(t, expectedCode, exitCode, "process exited with %d, want exit status %d", expectedCode, exitCode) + + stdoutData, err := os.ReadFile(stdoutFile.Name()) + assert.NoError(t, err) + + stderrData, err := os.ReadFile(stderrFile.Name()) + assert.NoError(t, err) + + assert.Empty(t, string(stdoutData)) + assert.NotEmpty(t, string(stderrData)) +} diff --git a/internal/pkg/conflictless/config.go b/internal/pkg/conflictless/config.go index 9e48469..c3f5209 100644 --- a/internal/pkg/conflictless/config.go +++ b/internal/pkg/conflictless/config.go @@ -1,11 +1,17 @@ package conflictless -import "fmt" +import ( + "fmt" + "strings" +) // FlagCollection is a collection of flags. type FlagCollection struct { Bump *string + ChangeFileFormat *string + ChangeFileName *string ChangelogFile *string + ChangeTypesCsv *string Command string Directory *string SkipVersionLinks bool @@ -13,12 +19,16 @@ type FlagCollection struct { // Config is the configuration for the CLI. type Config struct { - Flags FlagCollection Bump Bump + ChangeFileFormat string + Changelog *Changelog ChangelogFile string - RepositoryConfigFile string + ChangeFile string + ChangeTypesCsv string Directory string - Changelog *Changelog + Flags FlagCollection + RepositoryConfigFile string + RepositoryHeadFile string } func (cfg *Config) SetGenerateConfigsFromFlags() error { @@ -28,6 +38,14 @@ func (cfg *Config) SetGenerateConfigsFromFlags() error { return cfg.SetBumpFromFlags() } +func (cfg *Config) SetCreateConfigsFromFlags() error { + cfg.SetChangeTypesFromFlags() + cfg.SetDirectoryFromFlags() + cfg.SetChangeFileFromFlags() + + return cfg.SetChangeFileFormatFromFlags() +} + func (cfg *Config) SetCheckConfigsFromFlags() { cfg.SetDirectoryFromFlags() } @@ -65,3 +83,37 @@ func (cfg *Config) SetBumpFromFlags() error { return nil } + +func (cfg *Config) SetChangeTypesFromFlags() { + if cfg.Flags.ChangeTypesCsv != nil { + cfg.ChangeTypesCsv = *cfg.Flags.ChangeTypesCsv + } +} + +func (cfg *Config) SetChangeFileFromFlags() { + if cfg.Flags.ChangeFileName != nil { + cfg.ChangeFile = *cfg.Flags.ChangeFileName + } +} + +func (cfg *Config) SetChangeFileFormatFromFlags() error { + if cfg.Flags.ChangeFileFormat == nil { + return nil + } + + formatFlag := *cfg.Flags.ChangeFileFormat + formatFlag = strings.ToLower(formatFlag) + + switch formatFlag { + case "yaml": + cfg.ChangeFileFormat = "yaml" + case "yml": + cfg.ChangeFileFormat = "yml" + case "json": + cfg.ChangeFileFormat = "json" + default: + return fmt.Errorf("%w, %s", ErrInvalidFormatFlag, formatFlag) + } + + return nil +} diff --git a/internal/pkg/conflictless/config_test.go b/internal/pkg/conflictless/config_test.go index d2ad244..5cc83d6 100644 --- a/internal/pkg/conflictless/config_test.go +++ b/internal/pkg/conflictless/config_test.go @@ -21,9 +21,6 @@ func TestSetBumpFromFlags(t *testing.T) { {"minor", "minor", conflictless.BumpMinor}, {"major", "major", conflictless.BumpMajor}, } { - // Reinitialise testCase for parallel testing. - testCase := testCase - t.Run(testCase.description, func(t *testing.T) { t.Parallel() @@ -37,6 +34,57 @@ func TestSetBumpFromFlags(t *testing.T) { } } +func TestSetChangeFileFormatFromFlags(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + description string + format string + expected string + }{ + {"yml", "yml", "yml"}, + {"yaml", "yaml", "yaml"}, + {"json", "json", "json"}, + {"upper_case_json", "JSON", "json"}, + {"mixed_case_yaml", "yAmL", "yaml"}, + } { + t.Run(testCase.description, func(t *testing.T) { + t.Parallel() + + cfg := new(conflictless.Config) + cfg.Flags.ChangeFileFormat = &testCase.format + + err := cfg.SetChangeFileFormatFromFlags() + assert.NoError(t, err) + assert.Equal(t, testCase.expected, cfg.ChangeFileFormat) + }) + } +} + +func TestChangeFileFormatFromFlagsWithNil(t *testing.T) { + t.Parallel() + + cfg := new(conflictless.Config) + cfg.ChangeFileFormat = "yml" + cfg.Flags.ChangeFileFormat = nil + + err := cfg.SetChangeFileFormatFromFlags() + assert.NoError(t, err) + assert.Equal(t, "yml", cfg.ChangeFileFormat) +} + +func TestChangeFileFormatFromFlagsWithInvalid(t *testing.T) { + t.Parallel() + + invalidFormat := "foo" + + cfg := new(conflictless.Config) + cfg.Flags.ChangeFileFormat = &invalidFormat + + err := cfg.SetChangeFileFormatFromFlags() + assert.Error(t, err) +} + func TestSetBumpFromFlagsWhenInputIsInvalid(t *testing.T) { t.Parallel() diff --git a/internal/pkg/conflictless/conflictless.go b/internal/pkg/conflictless/conflictless.go index 5f56417..c8aea18 100644 --- a/internal/pkg/conflictless/conflictless.go +++ b/internal/pkg/conflictless/conflictless.go @@ -1 +1,5 @@ package conflictless + +const ( + writeFileMode = 0o644 +) diff --git a/internal/pkg/conflictless/create.go b/internal/pkg/conflictless/create.go new file mode 100644 index 0000000..d963fa9 --- /dev/null +++ b/internal/pkg/conflictless/create.go @@ -0,0 +1,27 @@ +package conflictless + +// Create creates a new change-file. +func Create(cfg *Config) { + err := cfg.SetCreateConfigsFromFlags() + if err != nil { + PrintErrorAndExit(err.Error(), usageCreateOnError) + } + + if cfg.ChangeFile == "" { + filename, err := ParseCurrentGitBranchAsFilename(cfg) + if err != nil { + PrintErrorAndExit(err.Error(), usageCreateOnError) + } + + cfg.ChangeFile = filename + } else { + cfg.ChangeFile += "." + cfg.ChangeFileFormat + } + + err = createChangeFile(cfg) + if err != nil { + PrintErrorAndExit(err.Error(), usageCreateOnError) + } + + PrintCreateSuccess(cfg) +} diff --git a/internal/pkg/conflictless/create_test.go b/internal/pkg/conflictless/create_test.go new file mode 100644 index 0000000..43c0063 --- /dev/null +++ b/internal/pkg/conflictless/create_test.go @@ -0,0 +1,195 @@ +package conflictless_test + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/ypjama/conflictless-keepachangelog/internal/pkg/conflictless" +) + +type createTestCase struct { + description string + format string + branchName string + changeTypesCSV string + contains []string + notContains []string + name *string +} + +func testCasesForCreate(t *testing.T) []createTestCase { + t.Helper() + + name := "loving-ladybird" + + return []createTestCase{ + { + description: "yml_format", + name: nil, + format: "yml", + branchName: "foo-bar-baz", + changeTypesCSV: "changed", + contains: []string{"---", "changed:\n -"}, + notContains: []string{"added", "deprecated", "removed", "fixed", "security"}, + }, + { + description: "yaml_format", + name: nil, + format: "yaml", + branchName: "123-create-and-fix-stuff", + changeTypesCSV: "security,fixed,added", + contains: []string{ + "---", + "added:\n -", + "fixed:\n -", + "security:\n -", + }, + notContains: []string{"changed", "deprecated", "removed"}, + }, + { + description: "json_format", + name: nil, + format: "json", + branchName: "changing-deprecating-and-removing", + changeTypesCSV: "changed,deprecated,removed", + contains: []string{ + "{\n", + "\n}", + "\n \"changed\": [\n \"\"\n ]", + "\n \"deprecated\": [\n \"\"\n ]", + "\n \"removed\": [\n \"\"\n ]", + }, + notContains: []string{"added", "fixed", "security"}, + }, + { + description: "name_given", + name: &name, + format: "yml", + branchName: "", + changeTypesCSV: "added", + contains: []string{"---", "added:\n -"}, + notContains: []string{"changed", "deprecated", "removed", "fixed", "security"}, + }, + } +} + +func setupCreate( + t *testing.T, + headFileContents, + format, + changeTypesCSV string, + name *string, +) (string, string, *conflictless.Config) { + t.Helper() + + changesDir, err := os.MkdirTemp(os.TempDir(), "changes") + assert.NoError(t, err) + + gitHeadFile := createTempFile(t, os.TempDir(), "test-generate.HEAD") + writeDataToFile(t, []byte(headFileContents), gitHeadFile) + + cfg := new(conflictless.Config) + cfg.RepositoryHeadFile = gitHeadFile.Name() + cfg.Flags.ChangeTypesCsv = &changeTypesCSV + cfg.Flags.Directory = &changesDir + cfg.Flags.ChangeFileFormat = &format + cfg.Flags.ChangeFileName = name + + return changesDir, gitHeadFile.Name(), cfg +} + +func TestCreate(t *testing.T) { + t.Parallel() + + for _, testCase := range testCasesForCreate(t) { + t.Run(testCase.description, func(t *testing.T) { + t.Parallel() + + changesDir, gitHeadFile, cfg := setupCreate( + t, + `ref: refs/heads/`+testCase.branchName, + testCase.format, + testCase.changeTypesCSV, + testCase.name, + ) + defer os.RemoveAll(changesDir) + defer os.Remove(gitHeadFile) + + conflictless.Create(cfg) + + filename := testCase.branchName + "." + testCase.format + if testCase.name != nil { + filename = *testCase.name + "." + testCase.format + } + + expectedName := filepath.Join(changesDir, filename) + + file, err := os.Stat(expectedName) + assert.NoError(t, err) + assert.False(t, file.IsDir()) + + contentBytes, err := os.ReadFile(expectedName) + assert.NoError(t, err) + + contents := string(contentBytes) + + for _, contains := range testCase.contains { + assert.Contains(t, contents, contains) + } + + for _, notContains := range testCase.notContains { + assert.NotContains(t, contents, notContains) + } + }) + } +} + +func TestCreateWithKanjiBranchName(t *testing.T) { + t.Parallel() + + kanjiBranchName := "朝日" + + changesDir, gitHeadFile, cfg := setupCreate( + t, + `ref: refs/heads/`+kanjiBranchName, + "yml", + "added,changed", + nil, + ) + defer os.RemoveAll(changesDir) + defer os.Remove(gitHeadFile) + + if os.Getenv("TEST_CREATE_WITH_KANJI") == "1" { + conflictless.Create(cfg) + + return + } + + stderrFile := createTempFile(t, os.TempDir(), "test-cli-create-with-invalid-flags-stderr") + defer os.Remove(stderrFile.Name()) + + //nolint:gosec // this is a test package so G204 doesn't really matter here. + cmd := exec.Command(os.Args[0], "-test.run=TestCreateWithKanjiBranchName") + cmd.Env = append(os.Environ(), "TEST_CREATE_WITH_KANJI=1") + cmd.Stderr = stderrFile + err := cmd.Run() + + assert.Error(t, err) + assert.IsType(t, new(exec.ExitError), err) + + exitErr := new(*exec.ExitError) + errors.As(err, exitErr) + + expectedCode := 2 + exitCode := (*exitErr).ExitCode() + + assert.Equal(t, expectedCode, exitCode, "process exited with %d, want exit status %d", expectedCode, exitCode) + + stderrData, err := os.ReadFile(stderrFile.Name()) + assert.NoError(t, err) + assert.Contains(t, string(stderrData), conflictless.ErrFailedToParseBranch.Error()) +} diff --git a/internal/pkg/conflictless/data.go b/internal/pkg/conflictless/data.go index 64ad5b0..d94f0bd 100644 --- a/internal/pkg/conflictless/data.go +++ b/internal/pkg/conflictless/data.go @@ -2,22 +2,42 @@ package conflictless import ( "bufio" + "encoding/json" "fmt" "io/fs" "os" "path/filepath" + "strings" "github.com/ypjama/conflictless-keepachangelog/pkg/schema" ) -func readChangeFiles(dir string) ([]fs.DirEntry, error) { +const ( + validExtensionJSON = ".json" + validExtensionYAML = ".yaml" + validExtensionYML = ".yml" +) + +func validateAndSanitizeDir(dir string) (string, error) { + dir = strings.TrimSpace(dir) + dir = strings.TrimSuffix(dir, string(os.PathSeparator)) + info, err := os.Stat(dir) if err != nil { - return nil, fmt.Errorf("%w. %w", ErrDirectoryRead, err) + return "", fmt.Errorf("%w. %w", ErrDirectoryRead, err) } if !info.IsDir() { - return nil, fmt.Errorf("%w. %s is not a directory", ErrDirectoryRead, dir) + return "", fmt.Errorf("%w. %s is not a directory", ErrDirectoryRead, dir) + } + + return dir, nil +} + +func readChangeFiles(dir string) ([]fs.DirEntry, error) { + dir, err := validateAndSanitizeDir(dir) + if err != nil { + return nil, err } files, err := os.ReadDir(dir) @@ -33,7 +53,7 @@ func readChangeFiles(dir string) ([]fs.DirEntry, error) { } ext := filepath.Ext(file.Name()) - if ext == ".yml" || ext == ".yaml" || ext == ".json" { + if ext == validExtensionJSON || ext == validExtensionYAML || ext == validExtensionYML { changeFiles = append(changeFiles, file) } } @@ -127,3 +147,70 @@ func removeChangeFiles(dir string) error { return nil } + +// EmptyData returns *schema.Data with single empty string on each of the defined change types. +func EmptyData(changeTypesCsv string) *schema.Data { + inputMap := map[string][]string{} + + for _, changeType := range strings.Split(changeTypesCsv, ",") { + inputMap[strings.ToLower(strings.TrimSpace(changeType))] = []string{""} + } + + data := new(schema.Data) + + bytes, err := json.Marshal(inputMap) + if err != nil { + return data + } + + err = json.Unmarshal(bytes, data) + if err != nil { + return data + } + + return data +} + +// IsJSONExtension takes a filename and returns true if file extension is json. +func IsJSONExtension(filename string) bool { + extLen := len(validExtensionJSON) + lower := strings.ToLower(strings.TrimSpace(filename)) + + return len(lower) > extLen && lower[len(lower)-extLen:] == validExtensionJSON +} + +func createChangeFile(cfg *Config) error { + dir, err := validateAndSanitizeDir(cfg.Directory) + if err != nil { + return err + } + + name := filepath.Join(dir, cfg.ChangeFile) + + if _, err := os.Stat(name); err == nil { + return fmt.Errorf("%w: %s", ErrFileAlreadyExists, name) + } + + data := EmptyData(cfg.ChangeTypesCsv) + + var contents string + + if IsJSONExtension(name) { + contents, err = data.ToJSON() + if err != nil { + return fmt.Errorf("%w. %w", ErrCreateWrite, err) + } + } else { + contents, err = data.ToYAML() + if err != nil { + return fmt.Errorf("%w. %w", ErrCreateWrite, err) + } + } + + err = os.WriteFile(name, []byte(contents), fs.FileMode(writeFileMode)) + if err != nil { + return fmt.Errorf("%w. %w", ErrCreateWrite, err) + } + + return nil +} diff --git a/internal/pkg/conflictless/data_test.go b/internal/pkg/conflictless/data_test.go new file mode 100644 index 0000000..6605145 --- /dev/null +++ b/internal/pkg/conflictless/data_test.go @@ -0,0 +1,80 @@ +package conflictless_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/ypjama/conflictless-keepachangelog/internal/pkg/conflictless" + + "github.com/stretchr/testify/assert" +) + +func TestIsJSON(t *testing.T) { + t.Parallel() + + type testCase struct { + description string + filename string + expected bool + } + + for _, testCase := range []testCase{ + {"no extension", "foo", false}, + {"json file", "foo.json", true}, + {"json file with upper case extension", "foo.JSON", true}, + {"yml file", "foo.yml", false}, + {"yaml file", "foo.yaml", false}, + {"full path to json file", "/tmp/foo/bar/baz.json", true}, + {"full path to yml file", "/tmp/foo/bar/baz.yml", false}, + } { + t.Run(testCase.description, func(t *testing.T) { + t.Parallel() + + actual := conflictless.IsJSONExtension(testCase.filename) + + assert.Equal(t, testCase.expected, actual) + }) + } +} + +func TestEmptyData(t *testing.T) { + t.Parallel() + + type testCase struct { + description string + changeTypesCsv string + expectedMinifiedJSON string + } + + for _, testCase := range []testCase{ + {"empty csv", "", "{}"}, + {"only added", "added", `{"added":[""]}`}, + {"added and changed", "added,changed", `{"added":[""],"changed":[""]}`}, + {"added and changed with spaces", " added , changed ", `{"added":[""],"changed":[""]}`}, + {"only fixed is valid", "addeds,change,fixed,insecurity", `{"fixed":[""]}`}, + {"mixed upper and lower case", "aDdeD,chANGed", `{"added":[""],"changed":[""]}`}, + { + "all change types", + "added,changed,deprecated,removed,fixed,security", + `{"added":[""],"changed":[""],"deprecated":[""],"removed":[""],"fixed":[""],"security":[""]}`, + }, + {"kanjis", "朝日", "{}"}, + } { + t.Run(testCase.description, func(t *testing.T) { + t.Parallel() + + data := conflictless.EmptyData(testCase.changeTypesCsv) + + bytes, _ := json.Marshal(data) + minifiedJSON := string(bytes) + + assert.Equal( + t, + testCase.expectedMinifiedJSON, + minifiedJSON, + fmt.Sprintf("expected %s but got %s", testCase.expectedMinifiedJSON, minifiedJSON), + ) + }) + } +} diff --git a/internal/pkg/conflictless/error.go b/internal/pkg/conflictless/error.go index ede81e8..4744449 100644 --- a/internal/pkg/conflictless/error.go +++ b/internal/pkg/conflictless/error.go @@ -5,8 +5,12 @@ import "errors" var ( ErrChangelogFileNotFound = errors.New("changelog file not found") ErrChangelogWrite = errors.New("changelog write error") + ErrCreateWrite = errors.New("change-file write error") ErrDirectoryRead = errors.New("directory read error") + ErrFailedToParseBranch = errors.New("failed to parse current git branch name") + ErrFileAlreadyExists = errors.New("file already exists") ErrFileRead = errors.New("file read error") ErrFileRemove = errors.New("file remove error") ErrInvalidBumpFlag = errors.New("invalid bump flag") + ErrInvalidFormatFlag = errors.New("invalid format flag") ) diff --git a/internal/pkg/conflictless/help.go b/internal/pkg/conflictless/help.go index 864ed43..c7fbac5 100644 --- a/internal/pkg/conflictless/help.go +++ b/internal/pkg/conflictless/help.go @@ -18,6 +18,8 @@ func Help() { switch topic { case commandCheck: usageCheck() + case commandCreate: + usageCreate() case commandGen: usageGenerate() case "": diff --git a/internal/pkg/conflictless/link_test.go b/internal/pkg/conflictless/link_test.go index 3ff6908..9ecf7c9 100644 --- a/internal/pkg/conflictless/link_test.go +++ b/internal/pkg/conflictless/link_test.go @@ -45,9 +45,6 @@ func TestSectionLink(t *testing.T) { "", }, } { - // Reinitialise testCase for parallel testing. - testCase := testCase - t.Run(testCase.description, func(t *testing.T) { t.Parallel() diff --git a/internal/pkg/conflictless/parse.go b/internal/pkg/conflictless/parse.go index 71963a9..6e25de5 100644 --- a/internal/pkg/conflictless/parse.go +++ b/internal/pkg/conflictless/parse.go @@ -2,14 +2,20 @@ package conflictless import ( "bufio" + "fmt" "os" "regexp" "strings" + + "golang.org/x/text/unicode/norm" ) -// ParseRepositoryURL detects the repository URL from the git config file. -func ParseRepositoryURL(cfg *Config) string { - file, err := os.Open(cfg.RepositoryConfigFile) +const ( + minRegexMatches = 2 +) + +func readFile(filepath string) string { + file, err := os.Open(filepath) if err != nil { return "" } @@ -29,16 +35,61 @@ func ParseRepositoryURL(cfg *Config) string { return "" } - gitConfig := string(fileBytes) + return string(fileBytes) +} + +// ParseRepositoryURL detects the repository URL from the git config file. +func ParseRepositoryURL(cfg *Config) string { + gitConfig := readFile(cfg.RepositoryConfigFile) re := regexp.MustCompile(`\[remote "origin"\][^[]+url = (.+)`) matches := re.FindStringSubmatch(gitConfig) - if len(matches) > 1 { - return HTTPSURLFromGitRemoteOrigin(matches[1]) + if len(matches) < minRegexMatches { + return "" + } + + return HTTPSURLFromGitRemoteOrigin(matches[1]) +} + +// ParseCurrentGitBranchAsFilename parses the current git branch name from the .git/HEAD file. +func ParseCurrentGitBranchAsFilename(cfg *Config) (string, error) { + headFile := readFile(cfg.RepositoryHeadFile) + extension := cfg.ChangeFileFormat + + re := regexp.MustCompile(`ref: refs/heads/(.+)`) + matches := re.FindStringSubmatch(headFile) + + if len(matches) < minRegexMatches { + return "", ErrFailedToParseBranch + } + + nameWithoutExtention := Basename(matches[1]) + if nameWithoutExtention == "" { + return "", ErrFailedToParseBranch + } + + return fmt.Sprintf("%s.%s", nameWithoutExtention, Basename(extension)), nil +} + +// Basename takes a string and converts it to valid basename. +func Basename(branch string) string { + branch = norm.NFD.String(strings.TrimSpace(branch)) + + for _, reg := range []struct { + expression string + replacement string + }{ + {`[\.\/_\\]+`, "-"}, + {`\p{Mn}+`, ""}, + {`[-]{2}`, "-"}, + {`[^a-zA-Z0-9\.\-]`, ""}, + } { + re := regexp.MustCompile(reg.expression) + branch = re.ReplaceAllString(branch, reg.replacement) } - return "" + return branch } // HTTPSURLFromGitRemoteOrigin converts a git remote origin URL to an HTTPS URL. diff --git a/internal/pkg/conflictless/parse_test.go b/internal/pkg/conflictless/parse_test.go index e8c0795..0a40c54 100644 --- a/internal/pkg/conflictless/parse_test.go +++ b/internal/pkg/conflictless/parse_test.go @@ -1,6 +1,7 @@ package conflictless_test import ( + "fmt" "os" "testing" @@ -58,9 +59,6 @@ func TestHTTPSURLFromGitRemoteOrigin(t *testing.T) { {"http", "http://gitlab.localhost/foo/bar.git", "https://gitlab.localhost/foo/bar"}, {"ssh", "git@github.com:golang/vscode-go.git", "https://github.com/golang/vscode-go"}, } { - // Reinitialise testCase for parallel testing. - testCase := testCase - t.Run(testCase.description, func(t *testing.T) { t.Parallel() @@ -76,3 +74,42 @@ func TestParseReleaseHeaders(t *testing.T) { actual := conflictless.ParseReleaseHeaders([]byte(changelogContent)) assert.Equal(t, []string{"Unreleased", "1.0.1", "1.0.0", "0.2.0", "0.1.0"}, actual) } + +func TestBasename(t *testing.T) { + t.Parallel() + + type testCase struct { + description string + input string + expected string + } + + for _, testCase := range []testCase{ + {"single ascii word", "foo", "foo"}, + {"spaces around word", " foo ", "foo"}, + {"semver", "1.0.0", "1-0-0"}, + {"semver with v", "v1.0.0", "v1-0-0"}, + {"release branch", "releases/1.0.0", "releases-1-0-0"}, + {"hotfix branch", "hotfix/did-a-thing", "hotfix-did-a-thing"}, + {"multiple-slashes", "foo/bar/-baz", "foo-bar-baz"}, + {"underscores", "foo_bar_baz", "foo-bar-baz"}, + {"underscores and dashes", "qux_quux-corge", "qux-quux-corge"}, + {"umlauts to ascii", "föö-bär-båz", "foo-bar-baz"}, + {"backwards slashes", `foo\bar\baz`, "foo-bar-baz"}, + {"kanjis are omitted", "朝日biiru", "biiru"}, + {"question mark is omitted", "foo-or-bar?", "foo-or-bar"}, + {"exclamation mark is omitted", "foo-of-course!", "foo-of-course"}, + } { + t.Run(testCase.description, func(t *testing.T) { + t.Parallel() + + actual := conflictless.Basename(testCase.input) + assert.Equal( + t, + testCase.expected, + actual, + fmt.Sprintf("with input '%s' we got %s but we wanted %s", testCase.input, actual, testCase.expected), + ) + }) + } +} diff --git a/internal/pkg/conflictless/print.go b/internal/pkg/conflictless/print.go index 1a0ff67..dd0ceb0 100644 --- a/internal/pkg/conflictless/print.go +++ b/internal/pkg/conflictless/print.go @@ -3,6 +3,7 @@ package conflictless import ( "fmt" "os" + "path/filepath" ) // PrintUsageAndExit prints the usage and exits. @@ -14,6 +15,8 @@ func PrintUsageAndExit(cfg *Config) { switch cfg.Flags.Command { case commandCheck: usageCheck() + case commandCreate: + usageCreate() case commandGen: usageGenerate() default: @@ -52,3 +55,8 @@ func PrintCheckSuccess(noContent bool) { //nolint:forbidigo fmt.Println(msg) } + +func PrintCreateSuccess(cfg *Config) { + //nolint:forbidigo + fmt.Printf("Created new change-file '%s' successfully!\n", filepath.Join(cfg.Directory, cfg.ChangeFile)) +} diff --git a/internal/pkg/conflictless/print_test.go b/internal/pkg/conflictless/print_test.go index 4bf0ec7..aafec08 100644 --- a/internal/pkg/conflictless/print_test.go +++ b/internal/pkg/conflictless/print_test.go @@ -55,12 +55,10 @@ func TestPrintUsageAndExit(t *testing.T) { for _, crasher := range []string{ "no-cmd", "check", + "create", "generate", "usage", } { - // Reinitialise crasher for parallel testing. - crasher := crasher - t.Run(crasher, func(t *testing.T) { t.Parallel() @@ -90,9 +88,6 @@ func TestPrintCheckSuccess(t *testing.T) { {"no content", true, "No changes found!\n"}, {"content", false, "Change files are valid!\n"}, } { - // Reinitialise testCase for parallel testing. - testCase := testCase - t.Run("", func(t *testing.T) { file := createTempFile(t, os.TempDir(), "stdout-"+url.QueryEscape(testCase.description)) defer os.Remove(file.Name()) diff --git a/internal/pkg/conflictless/usage.go b/internal/pkg/conflictless/usage.go index cfb29ec..619b284 100644 --- a/internal/pkg/conflictless/usage.go +++ b/internal/pkg/conflictless/usage.go @@ -16,6 +16,31 @@ const ( "-d, --dir\n" + flagDescriptionIndentation + "Directory where to look for change-files (default: changes)" + flagDescriptionFormat = flagIndentation + + "-f, --format\n" + + flagDescriptionIndentation + + "File format and extension yml/yaml/json for the change-file (default: yml)" + flagDescriptionTypes = flagIndentation + + "-t, --types\n" + + flagDescriptionIndentation + + "Types of changes you want for the change-file (default: changed)\n\n" + + flagDescriptionIndentation + + "Multiple values can be given by separating values with commas.\n" + + flagDescriptionIndentation + + "Example: '--format added,changed,deprecated,removed,fixed,security'." + + flagDescriptionIndentation + flagDescriptionNameForCreate = flagIndentation + + "-n, --name\n" + + flagDescriptionIndentation + + "Name for the change-file without file extension\n\n" + + flagDescriptionIndentation + + "If this flag is not given the name will be derived from the name of the\n" + + flagDescriptionIndentation + + "current git branch you're on." + flagDescriptionDirForCreate = flagIndentation + + "-d, --dir\n" + + flagDescriptionIndentation + + "Directory where the change-file should be created (default: changes)" flagDescriptionBump = flagIndentation + "-b, --bump\n" + flagDescriptionIndentation + @@ -32,11 +57,11 @@ func usageText() string { The commands are: check Checks that change-files are valid + create Creates a new change-file generate Generates a version entry to changelog file help Prints this help message Use "conflictless help " for more information about that topic. - ` } @@ -68,6 +93,23 @@ The flags are: ) } +func usageTextForCreate() string { + return fmt.Sprintf(`Usage: conflictless create [flags] + +The flags are: + +%s +%s +%s +%s +`, + flagDescriptionDirForCreate, + flagDescriptionFormat, + flagDescriptionTypes, + flagDescriptionNameForCreate, + ) +} + func usage() { fmt.Fprint(os.Stdout, usageText()) } @@ -84,6 +126,14 @@ func usageCheckOnError() { fmt.Fprint(os.Stderr, usageTextForCheck()) } +func usageCreate() { + fmt.Fprint(os.Stdout, usageTextForCreate()) +} + +func usageCreateOnError() { + fmt.Fprint(os.Stderr, usageTextForCreate()) +} + func usageGenerate() { fmt.Fprint(os.Stdout, usageTextForGenerate()) } diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index bfadf0b..b781a74 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -1,6 +1,7 @@ package schema import ( + "bytes" "encoding/json" "errors" "fmt" @@ -12,14 +13,18 @@ import ( "gopkg.in/yaml.v3" ) +const ( + yamlIndent = 2 +) + // Data for single changelog section and single change-file. type Data struct { - Added []string `json:"added" yaml:"added"` - Changed []string `json:"changed" yaml:"changed"` - Deprecated []string `json:"deprecated" yaml:"deprecated"` - Removed []string `json:"removed" yaml:"removed"` - Fixed []string `json:"fixed" yaml:"fixed"` - Security []string `json:"security" yaml:"security"` + Added []string `json:"added,omitempty" yaml:"added,omitempty"` + Changed []string `json:"changed,omitempty" yaml:"changed,omitempty"` + Deprecated []string `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` + Removed []string `json:"removed,omitempty" yaml:"removed,omitempty"` + Fixed []string `json:"fixed,omitempty" yaml:"fixed,omitempty"` + Security []string `json:"security,omitempty" yaml:"security,omitempty"` } // IsEmpty returns true if all fields are empty. @@ -32,6 +37,35 @@ func (d *Data) IsEmpty() bool { len(d.Security) == 0 } +// ToJSON returns contents of Data as pretty print JSON string. +func (d *Data) ToJSON() (string, error) { + bytes, err := json.MarshalIndent(d, "", " ") + if err != nil { + return "{}", fmt.Errorf("%w", err) + } + + return string(bytes), nil +} + +// ToYAML returns contents of Data as string which can used in a YAML file. +func (d *Data) ToYAML() (string, error) { + contents := "---\n" + + var buf bytes.Buffer + + enc := yaml.NewEncoder(&buf) + enc.SetIndent(yamlIndent) + + err := enc.Encode(d) + if err != nil { + return contents, fmt.Errorf("%w", err) + } + + contents += buf.String() + + return contents, nil +} + // JSON Schema for validating change-files. // // The $id urn:uuid: is a UUIDv5 calculated with namespace "6ba7b811-9dad-11d1-80b4-00c04fd430c8" (@url) diff --git a/pkg/schema/schema_test.go b/pkg/schema/schema_test.go index 549a32e..71cd7e2 100644 --- a/pkg/schema/schema_test.go +++ b/pkg/schema/schema_test.go @@ -20,6 +20,49 @@ func TestIsEmpty(t *testing.T) { assert.False(t, data.IsEmpty()) } +func TestToJSON(t *testing.T) { + t.Parallel() + + data := new(schema.Data) + + data.Added = []string{"foo", "bar"} + data.Removed = []string{"baz"} + + result, err := data.ToJSON() + + assert.NoError(t, err) + assert.NotEmpty(t, result) + assert.Contains(t, result, "{\n") + assert.Contains(t, result, "\n \"added\": [\n \"foo\",\n \"bar\"\n ]") + assert.Contains(t, result, "\n \"removed\": [\n \"baz\"\n ]") + assert.Contains(t, result, "\n}") + assert.NotContains(t, result, "changed") + assert.NotContains(t, result, "deprecated") + assert.NotContains(t, result, "fixed") + assert.NotContains(t, result, "security") +} + +func TestToYAML(t *testing.T) { + t.Parallel() + + data := new(schema.Data) + + data.Changed = []string{"foo"} + data.Security = []string{"bar", "baz"} + + result, err := data.ToYAML() + + assert.NoError(t, err) + assert.NotEmpty(t, result) + assert.Contains(t, result, "---\n") + assert.Contains(t, result, "changed:\n - foo\n") + assert.Contains(t, result, "security:\n - bar\n - baz\n") + assert.NotContains(t, result, "added") + assert.NotContains(t, result, "deprecated") + assert.NotContains(t, result, "removed") + assert.NotContains(t, result, "fixed") +} + func TestParseJSONWhenInvalid(t *testing.T) { t.Parallel() @@ -44,9 +87,6 @@ added: schema.ErrSchemaLoader, }, } { - // Reinitialise testCase for parallel testing. - testCase := testCase - t.Run(testCase.description, func(t *testing.T) { t.Parallel() @@ -81,9 +121,6 @@ func TestParseJSONWhenValid(t *testing.T) { }`, }, } { - // Reinitialise testCase for parallel testing. - testCase := testCase - t.Run(testCase.description, func(t *testing.T) { t.Parallel() @@ -128,9 +165,6 @@ changed: { foo: "bar" } {"not an yaml", `foo, bar, baz`, schema.ErrYamlToJSON}, {"unconvertable yaml", `added: { false: { true: foo } }`, schema.ErrYamlToJSON}, } { - // Reinitialise testCase for parallel testing. - testCase := testCase - t.Run(testCase.description, func(t *testing.T) { t.Parallel() @@ -191,9 +225,6 @@ removed: }, {"Simple JSON", `{"added":["foo"]}`}, } { - // Reinitialise testCase for parallel testing. - testCase := testCase - t.Run(testCase.description, func(t *testing.T) { t.Parallel()