Skip to content

Commit 69ae838

Browse files
committed
feat: monorepo configuration now support multiple paths, also added several refactors.
Among the refactors: - Configuration is now single-handedly handled using appcontext.AppContext (Viper/Cobra populate the appcontext.AppContext which is used as a source of truth) - Code readability improvements (use of constants throughout the code, octal permissions, etc.)
1 parent 912f222 commit 69ae838

File tree

18 files changed

+404
-387
lines changed

18 files changed

+404
-387
lines changed

cmd/release.go

Lines changed: 21 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"bytes"
55
"context"
6+
"encoding/json"
67
"fmt"
78
"os"
89

@@ -11,7 +12,6 @@ import (
1112
"github.com/spf13/cobra"
1213

1314
"github.com/s0ders/go-semver-release/v6/internal/appcontext"
14-
"github.com/s0ders/go-semver-release/v6/internal/branch"
1515
"github.com/s0ders/go-semver-release/v6/internal/ci"
1616
"github.com/s0ders/go-semver-release/v6/internal/gpg"
1717
"github.com/s0ders/go-semver-release/v6/internal/parser"
@@ -20,6 +20,12 @@ import (
2020
"github.com/s0ders/go-semver-release/v6/internal/tag"
2121
)
2222

23+
const (
24+
MessageDryRun string = "dry-run enabled, next release found"
25+
MessageNewRelease string = "new release found"
26+
MessageNoNewRelease string = "no new release"
27+
)
28+
2329
func NewReleaseCmd(ctx *appcontext.AppContext) *cobra.Command {
2430
releaseCmd := &cobra.Command{
2531
Use: "release <REPOSITORY_PATH_OR_URL>",
@@ -37,14 +43,17 @@ func NewReleaseCmd(ctx *appcontext.AppContext) *cobra.Command {
3743
return fmt.Errorf("configuring GPG key: %w", err)
3844
}
3945

40-
ctx.Rules, err = configureRules(ctx)
41-
if err != nil {
42-
return fmt.Errorf("loading rules configuration: %w", err)
43-
}
46+
if ctx.RulesCfg.String() == "{}" {
47+
ctx.Logger.Debug().Msg("no rules configuration provided, using default release rules")
4448

45-
ctx.Branches, err = configureBranches(ctx)
46-
if err != nil {
47-
return fmt.Errorf("loading branches configuration: %w", err)
49+
b, err := json.Marshal(rule.Default)
50+
if err != nil {
51+
return fmt.Errorf("marshalling default rules: %w", err)
52+
}
53+
54+
if err = ctx.RulesCfg.Set(string(b)); err != nil {
55+
return fmt.Errorf("setting default rules flag: %w", err)
56+
}
4857
}
4958

5059
origin = remote.New(ctx.RemoteName, ctx.AccessToken)
@@ -69,7 +78,7 @@ func NewReleaseCmd(ctx *appcontext.AppContext) *cobra.Command {
6978

7079
err = ci.GenerateGitHubOutput(semver, output.Branch, ci.WithNewRelease(release), ci.WithTagPrefix(ctx.TagPrefix), ci.WithProject(project))
7180
if err != nil {
72-
return fmt.Errorf("generating github output: %w", err)
81+
return fmt.Errorf("generating GitHub output: %w", err)
7382
}
7483

7584
logEvent := ctx.Logger.Info()
@@ -85,11 +94,11 @@ func NewReleaseCmd(ctx *appcontext.AppContext) *cobra.Command {
8594

8695
switch {
8796
case !release:
88-
logEvent.Msg("no new release")
97+
logEvent.Msg(MessageNoNewRelease)
8998
case release && ctx.DryRun:
90-
logEvent.Msg("dry-run enabled, next release found")
99+
logEvent.Msg(MessageDryRun)
91100
default:
92-
logEvent.Msg("new release found")
101+
logEvent.Msg(MessageNewRelease)
93102

94103
err = tagger.TagRepository(repository, semver, commitHash)
95104
if err != nil {
@@ -112,34 +121,6 @@ func NewReleaseCmd(ctx *appcontext.AppContext) *cobra.Command {
112121
return releaseCmd
113122
}
114123

115-
func configureRules(ctx *appcontext.AppContext) (rule.Rules, error) {
116-
flag := ctx.RulesFlag
117-
118-
if flag.String() == "{}" {
119-
return rule.Default, nil
120-
}
121-
122-
rulesJSON := map[string][]string(flag)
123-
124-
unmarshalledRules, err := rule.Unmarshall(rulesJSON)
125-
if err != nil {
126-
return unmarshalledRules, fmt.Errorf("parsing rules configuration: %w", err)
127-
}
128-
129-
return unmarshalledRules, nil
130-
}
131-
132-
func configureBranches(ctx *appcontext.AppContext) ([]branch.Branch, error) {
133-
branchesJSON := []map[string]any(ctx.BranchesFlag)
134-
135-
unmarshalledBranches, err := branch.Unmarshall(branchesJSON)
136-
if err != nil {
137-
return nil, fmt.Errorf("parsing branches configuration: %w", err)
138-
}
139-
140-
return unmarshalledBranches, nil
141-
}
142-
143124
func configureGPGKey(ctx *appcontext.AppContext) (*openpgp.Entity, error) {
144125
flag := ctx.GPGKeyPath
145126

cmd/release_test.go

Lines changed: 71 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,7 @@ import (
1515
assertion "github.com/stretchr/testify/assert"
1616

1717
"github.com/s0ders/go-semver-release/v6/internal/appcontext"
18-
"github.com/s0ders/go-semver-release/v6/internal/branch"
1918
"github.com/s0ders/go-semver-release/v6/internal/gittest"
20-
"github.com/s0ders/go-semver-release/v6/internal/rule"
2119
"github.com/s0ders/go-semver-release/v6/internal/tag"
2220
)
2321

@@ -48,13 +46,15 @@ func TestReleaseCmd_ConfigurationAsEnvironmentVariable(t *testing.T) {
4846
assert.Equal(accessToken, th.Ctx.AccessToken, "access token flag value should be equal to environment variable value")
4947
}
5048

49+
// TODO: add monorepo config key
50+
5151
func TestReleaseCmd_ConfigurationAsFile(t *testing.T) {
5252
assert := assertion.New(t)
5353

5454
taggerName := "My CI Robot"
5555
taggerEmail := "my-robot@release.ci"
5656

57-
// Create configuration file
57+
// Create a configuration file
5858
cfgContent := []byte(`
5959
git-name: ` + taggerName + `
6060
git-email: ` + taggerEmail + `
@@ -85,7 +85,7 @@ rules:
8585
err = os.WriteFile(cfgFilePath, cfgContent, 0o644)
8686
checkErr(t, err, "writing configuration file")
8787

88-
// Create test repository
88+
// Create a test repository
8989
masterCommits := []string{
9090
"fix", // 0.0.1
9191
"feat!", // 1.0.0 (breaking change)
@@ -855,45 +855,87 @@ func TestReleaseCmd_Monorepo_MixedRelease(t *testing.T) {
855855
assert.Equal(len(expectedOutputs), i)
856856
}
857857

858-
func TestReleaseCmd_ConfigureRules_DefaultRules(t *testing.T) {
858+
func TestReleaseCmd_Monorepo_ProjectWithPaths(t *testing.T) {
859859
assert := assertion.New(t)
860-
ctx := appcontext.New()
861860

862-
rules, err := configureRules(ctx)
863-
checkErr(t, err, "configuring rules")
861+
testRepository, err := gittest.NewRepository()
862+
checkErr(t, err, "creating sample repository")
864863

865-
assert.Equal(rule.Default, rules)
866-
}
864+
defer func() {
865+
err = testRepository.Remove()
866+
checkErr(t, err, "removing repository")
867+
}()
867868

868-
func TestReleaseCmd_ConfigureBranches_NoBranches(t *testing.T) {
869-
assert := assertion.New(t)
870-
ctx := appcontext.New()
869+
// "bar" commits, "foo" has no applicable commits
870+
_, err = testRepository.AddCommitWithSpecificFile("feat!", "./bar/foo.txt")
871+
checkErr(t, err, "adding commit")
872+
_, err = testRepository.AddCommitWithSpecificFile("fix", "./bar2/foo2.txt")
873+
checkErr(t, err, "adding commit")
874+
_, err = testRepository.AddCommitWithSpecificFile("fix", "./bar2/foo2.txt")
875+
checkErr(t, err, "adding commit")
871876

872-
_, err := configureBranches(ctx)
873-
assert.ErrorIs(err, branch.ErrNoBranch)
874-
}
877+
th := NewTestHelper(t)
878+
err = th.SetFlags(map[string]string{
879+
BranchesConfiguration: `[{"name": "master"}]`,
880+
MonorepoConfiguration: `[{"name": "bar", "paths": ["bar", "bar2"]}]`,
881+
})
882+
checkErr(t, err, "setting flags")
875883

876-
func TestReleaseCmd_InvalidCustomRules(t *testing.T) {
877-
assert := assertion.New(t)
878-
ctx := appcontext.New()
884+
out, err := th.ExecuteCommand("release", testRepository.Path)
885+
checkErr(t, err, "executing command")
879886

880-
ctx.RulesFlag = map[string][]string{
881-
"minor": {"feat"},
882-
"patch": {"feat"},
887+
i := 0
888+
expectedOutputs := []cmdOutput{
889+
{
890+
Message: "new release found",
891+
Version: "1.0.2",
892+
NewRelease: true,
893+
Branch: "master",
894+
Project: "bar",
895+
},
883896
}
884897

885-
_, err := configureRules(ctx)
886-
assert.ErrorIs(err, rule.ErrDuplicateReleaseRule, "should have failed parsing invalid custom rule")
898+
scanner := bufio.NewScanner(bytes.NewReader(out))
899+
900+
for scanner.Scan() {
901+
rawOutput := scanner.Bytes()
902+
903+
actualOutput := cmdOutput{}
904+
905+
err = json.Unmarshal(rawOutput, &actualOutput)
906+
checkErr(t, err, "unmarshalling output")
907+
908+
assert.Equal(expectedOutputs[i], actualOutput)
909+
i++
910+
}
911+
err = scanner.Err()
912+
checkErr(t, err, "scanning error")
913+
assert.Equal(len(expectedOutputs), i)
887914
}
888915

889-
func TestReleaseCmd_InvalidBranch(t *testing.T) {
916+
func TestReleaseCmd_Monorepo_ExclusivePathAndPaths(t *testing.T) {
890917
assert := assertion.New(t)
891-
ctx := appcontext.New()
892918

893-
ctx.BranchesFlag = []map[string]any{{"prerelease": true}}
919+
testRepository, err := gittest.NewRepository()
920+
checkErr(t, err, "creating sample repository")
921+
922+
defer func() {
923+
err = testRepository.Remove()
924+
checkErr(t, err, "removing repository")
925+
}()
926+
927+
// "bar" commits, "foo" has no applicable commits
928+
_, err = testRepository.AddCommitWithSpecificFile("feat!", "./bar/foo.txt")
929+
checkErr(t, err, "adding commit")
930+
931+
th := NewTestHelper(t)
932+
err = th.SetFlags(map[string]string{
933+
BranchesConfiguration: `[{"name": "master"}]`,
934+
MonorepoConfiguration: `[{"name": "bar", "path": "bar", "paths": ["./bar/", "./bar2/"]}]`,
935+
})
894936

895-
_, err := configureBranches(ctx)
896-
assert.ErrorIs(err, branch.ErrNoName, "should have failed parsing branch with no name")
937+
// TODO: refine to target specific monorepo.ErrExlusive...
938+
assert.Error(err, "should have failed trying to set exclusive path and paths")
897939
}
898940

899941
func TestReleaseCmd_InvalidArmoredKeyPath(t *testing.T) {

cmd/root.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,16 @@ func NewRootCommand(ctx *appcontext.AppContext) *cobra.Command {
5454
}
5555

5656
rootCmd.PersistentFlags().StringVar(&ctx.AccessToken, AccessTokenConfiguration, "", "Access token used to push tag to Git remote")
57-
rootCmd.PersistentFlags().VarP(&ctx.BranchesFlag, BranchesConfiguration, "b", "An array of branches such as [{\"name\": \"main\"}, {\"name\": \"rc\", \"prerelease\": true}]")
58-
rootCmd.PersistentFlags().StringVar(&ctx.BuildMetadata, BuildMetadataConfiguration, "", "Build metadata (e.g. build number) that will be appended to the SemVer")
57+
rootCmd.PersistentFlags().VarP(&ctx.BranchesCfg, BranchesConfiguration, "b", "An array of branches configuration such as [{\"name\": \"main\"}, {\"name\": \"rc\", \"prerelease\": true}]")
58+
rootCmd.PersistentFlags().StringVar(&ctx.BuildMetadata, BuildMetadataConfiguration, "", "Build metadata that will be appended to the SemVer")
5959
rootCmd.PersistentFlags().StringVar(&ctx.CfgFile, "config", "", "Configuration file path (default \"./"+defaultConfigFile+"."+configFileFormat+"\")")
6060
rootCmd.PersistentFlags().BoolVarP(&ctx.DryRun, DryRunConfiguration, "d", false, "Only compute the next SemVer, do not push any tag")
6161
rootCmd.PersistentFlags().StringVar(&ctx.GitEmail, GitEmailConfiguration, "go-semver@release.ci", "Email used in semantic version tags")
62-
rootCmd.PersistentFlags().StringVar(&ctx.GitName, GitNameConfiguration, "Go Semver Release", "Name used in semantic version tags")
63-
rootCmd.PersistentFlags().StringVar(&ctx.GPGKeyPath, GPGPathConfiguration, "", "Path to an armored GPG key used to sign produced tags")
64-
rootCmd.PersistentFlags().Var(&ctx.MonorepositoryCfg, MonorepoConfiguration, "An array of branches such as [{\"name\": \"foo\", \"path\": \"./foo/\"}]")
62+
rootCmd.PersistentFlags().StringVar(&ctx.GitName, GitNameConfiguration, "Go Semver Release", "Name used in semantic version Git tags")
63+
rootCmd.PersistentFlags().StringVar(&ctx.GPGKeyPath, GPGPathConfiguration, "", "Path to an armored GPG key used to sign produced Git tags")
64+
rootCmd.PersistentFlags().Var(&ctx.MonorepositoryCfg, MonorepoConfiguration, "An array of monorepository configuration such as [{\"name\": \"foo\", \"path\": \"./foo/\"}]")
6565
rootCmd.PersistentFlags().StringVar(&ctx.RemoteName, RemoteNameConfiguration, "origin", "Name of the Git repository remote")
66-
rootCmd.PersistentFlags().Var(&ctx.RulesFlag, RulesConfiguration, "A hashmap of array such as {\"minor\": [\"feat\"], \"patch\": [\"fix\", \"perf\"]} ]")
66+
rootCmd.PersistentFlags().Var(&ctx.RulesCfg, RulesConfiguration, "A hashmap of array such as {\"minor\": [\"feat\"], \"patch\": [\"fix\", \"perf\"]} ]")
6767
rootCmd.PersistentFlags().StringVar(&ctx.TagPrefix, TagPrefixConfiguration, "v", "Prefix added to the version tag name")
6868
rootCmd.PersistentFlags().BoolVarP(&ctx.Verbose, "verbose", "v", false, "Verbose output")
6969

@@ -76,6 +76,13 @@ func NewRootCommand(ctx *appcontext.AppContext) *cobra.Command {
7676
return rootCmd
7777
}
7878

79+
// initializeConfig manages how the configuration variables of the application are initialized.
80+
// It loads configuration with the following order of precedence:
81+
// (1) command line flags, (2) environment variables, (3) viper configuration file.
82+
// The key point to understand how configuration management is handled throughout this application is that everything
83+
// ends up being mapped to the variable inside appcontext.AppContext that are either populated by Cobra using CLI flag
84+
// values, or by Viper using configuration file or environment variables. Viper is only used to bind flags and
85+
// configuration file values.
7986
func initializeConfig(cmd *cobra.Command, ctx *appcontext.AppContext) error {
8087
if ctx.CfgFile != "" {
8188
ctx.Viper.SetConfigFile(ctx.CfgFile)

internal/appcontext/app_context.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,9 @@ import (
1515

1616
type AppContext struct {
1717
Viper *viper.Viper
18-
Branches []branch.Branch
19-
Rules rule.Rules
20-
BranchesFlag branch.Flag
18+
BranchesCfg branch.Flag
2119
MonorepositoryCfg monorepo.Flag
22-
RulesFlag rule.Flag
20+
RulesCfg rule.Flag
2321
Logger zerolog.Logger
2422
CfgFile string
2523
GitName string

internal/branch/branch.go

Lines changed: 9 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,53 +3,18 @@ package branch
33

44
import (
55
"errors"
6-
"fmt"
76
)
87

8+
type Config struct {
9+
Items []Item `yaml:"branches" mapstructure:"branches"`
10+
}
11+
12+
type Item struct {
13+
Name string `yaml:"name" json:"name" mapstructure:"name"`
14+
Prerelease bool `yaml:"prerelease" json:"prerelease" mapstructure:"prerelease"`
15+
}
16+
917
var (
1018
ErrNoBranch = errors.New("no branch configuration")
1119
ErrNoName = errors.New("no name in branch configuration")
1220
)
13-
14-
type Branch struct {
15-
Name string
16-
Prerelease bool
17-
}
18-
19-
// Unmarshall takes a raw Viper configuration and returns a slice of Branch representing a branch configuration.
20-
func Unmarshall(input []map[string]any) ([]Branch, error) {
21-
if len(input) == 0 {
22-
return nil, ErrNoBranch
23-
}
24-
25-
branches := make([]Branch, len(input))
26-
27-
for i, b := range input {
28-
29-
name, ok := b["name"]
30-
if !ok {
31-
return nil, ErrNoName
32-
}
33-
34-
stringName, ok := name.(string)
35-
if !ok {
36-
return nil, fmt.Errorf("could not assert that the \"name\" property of the branch configuration is a string")
37-
}
38-
39-
branch := Branch{Name: stringName}
40-
41-
prerelease, ok := b["prerelease"]
42-
if ok {
43-
boolPrerelease, ok := prerelease.(bool)
44-
if !ok {
45-
return nil, fmt.Errorf("could not assert that the \"prerelease\" property of the branch configuration is a bool")
46-
}
47-
48-
branch.Prerelease = boolPrerelease
49-
}
50-
51-
branches[i] = branch
52-
}
53-
54-
return branches, nil
55-
}

0 commit comments

Comments
 (0)