diff --git a/.gitignore b/.gitignore index d0cab510b36..dcba2afc44f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ dockerize /cadence-bench /cadence-cassandra-tool /cadence-sql-tool +/cadence-releaser # SQLite databases cadence.db* diff --git a/Makefile b/Makefile index ec97adb3ca3..51bd35f16a6 100644 --- a/Makefile +++ b/Makefile @@ -532,6 +532,12 @@ cadence-bench: $(BINS_DEPEND_ON) $Q echo "compiling cadence-bench with OS: $(GOOS), ARCH: $(GOARCH)" $Q ./scripts/build-with-ldflags.sh -o $@ cmd/bench/main.go + +BINS += cadence-releaser +cadence-releaser: $(BINS_DEPEND_ON) + $Q echo "compiling cadence-releaser with OS: $(GOOS), ARCH: $(GOARCH)" + $Q ./scripts/build-with-ldflags.sh -o $@ cmd/tools/releaser/releaser.go + .PHONY: go-generate bins tools release clean bins: $(BINS) ## Build all binaries, and any fast codegen needed (does not refresh wrappers or mocks) diff --git a/cmd/tools/releaser/internal/console/console.go b/cmd/tools/releaser/internal/console/console.go new file mode 100644 index 00000000000..cad6e618f98 --- /dev/null +++ b/cmd/tools/releaser/internal/console/console.go @@ -0,0 +1,90 @@ +package console + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" +) + +// Manager handles console interactions +type Manager struct { + reader io.Reader + writer io.Writer +} + +// NewManager creates a new console manager +func NewManager(reader io.Reader, writer io.Writer) *Manager { + return &Manager{ + reader: reader, + writer: writer, + } +} + +// Confirm asks for user confirmation and returns true for 'y', false for 'n' +func (m *Manager) Confirm(ctx context.Context, message string) (bool, error) { + return m.ConfirmWithDefault(ctx, message, false) +} + +// ConfirmWithDefault asks for user confirmation with a default value +// Returns defaultValue if user just presses enter +func (m *Manager) ConfirmWithDefault(ctx context.Context, message string, defaultValue bool) (bool, error) { + prompt := fmt.Sprintf("%s [y/N]: ", message) + if defaultValue { + prompt = fmt.Sprintf("%s [Y/n]: ", message) + } + + _, _ = fmt.Fprint(m.writer, prompt) + + inputChan := make(chan inputResult, 1) + + // Start goroutine to read input + go func() { + scanner := bufio.NewScanner(m.reader) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + inputChan <- inputResult{err: fmt.Errorf("failed to read input: %w", err)} + return + } + // EOF or no input - use default + inputChan <- inputResult{text: "", err: nil} + return + } + inputChan <- inputResult{text: scanner.Text(), err: nil} + }() + + var result inputResult + // Wait for either input or context cancellation + select { + case <-ctx.Done(): + // Context was cancelled + return false, ctx.Err() + case result = <-inputChan: + } + // Got input from user + if result.err != nil { + return false, result.err + } + + input := strings.TrimSpace(strings.ToLower(result.text)) + + // Empty input uses default + if input == "" { + return defaultValue, nil + } + + switch input { + case "y", "yes": + return true, nil + case "n", "no": + return false, nil + default: + return false, fmt.Errorf("invalid input: %s", input) + } +} + +type inputResult struct { + text string + err error +} diff --git a/cmd/tools/releaser/internal/console/console_test.go b/cmd/tools/releaser/internal/console/console_test.go new file mode 100644 index 00000000000..3b699c40f0c --- /dev/null +++ b/cmd/tools/releaser/internal/console/console_test.go @@ -0,0 +1,200 @@ +package console + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfirm(t *testing.T) { + tests := []struct { + name string + input string + expected bool + hasError bool + }{ + {"yes response", "y\n", true, false}, + {"Yes response", "Y\n", true, false}, + {"yes full", "yes\n", true, false}, + {"no response", "n\n", false, false}, + {"No response", "N\n", false, false}, + {"no full", "no\n", false, false}, + {"empty input uses default false", "\n", false, false}, + {"invalid input", "maybe\n", false, true}, + {"whitespace input", " \n", false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := strings.NewReader(tt.input) + writer := &bytes.Buffer{} + manager := NewManager(reader, writer) + + ctx := context.Background() + result, err := manager.Confirm(ctx, "Test message") + + if tt.hasError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + + // Check that prompt was written + output := writer.String() + assert.Contains(t, output, "Test message [y/N]:") + }) + } +} + +func TestConfirmWithDefault(t *testing.T) { + tests := []struct { + name string + input string + defaultValue bool + expected bool + hasError bool + }{ + {"yes with default false", "y\n", false, true, false}, + {"no with default true", "n\n", true, false, false}, + {"empty uses default true", "\n", true, true, false}, + {"empty uses default false", "\n", false, false, false}, + {"invalid input", "invalid\n", true, false, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := strings.NewReader(tt.input) + writer := &bytes.Buffer{} + manager := NewManager(reader, writer) + + ctx := context.Background() + result, err := manager.ConfirmWithDefault(ctx, "Test message", tt.defaultValue) + + if tt.hasError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + + // Check prompt format based on default + output := writer.String() + if tt.defaultValue { + assert.Contains(t, output, "[Y/n]:") + } else { + assert.Contains(t, output, "[y/N]:") + } + }) + } +} + +// blockingReader blocks on Read until unblocked +type blockingReader struct { + unblock chan struct{} + data []byte + read bool +} + +func newBlockingReader() *blockingReader { + return &blockingReader{ + unblock: make(chan struct{}), + data: []byte("y\n"), + } +} + +func (br *blockingReader) Read(p []byte) (n int, err error) { + if br.read { + return 0, io.EOF + } + + // Block until unblocked + <-br.unblock + + br.read = true + n = copy(p, br.data) + return n, nil +} + +func (br *blockingReader) Unblock() { + close(br.unblock) +} + +func TestContextCancellation(t *testing.T) { + t.Run("context cancelled before input", func(t *testing.T) { + // Create a context that's already cancelled + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + reader := strings.NewReader("y\n") // This won't be read due to cancellation + writer := &bytes.Buffer{} + manager := NewManager(reader, writer) + + result, err := manager.ConfirmWithDefault(ctx, "Test", false) + + require.Error(t, err) + assert.True(t, errors.Is(err, context.Canceled)) + assert.False(t, result) + }) + + t.Run("context cancelled during input wait", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + // Use a blocking reader that actually blocks + reader := newBlockingReader() + writer := &bytes.Buffer{} + manager := NewManager(reader, writer) + + // Cancel context after a short delay + go func() { + time.Sleep(10 * time.Millisecond) + cancel() + }() + + result, err := manager.ConfirmWithDefault(ctx, "Test", false) + + require.Error(t, err) + assert.True(t, errors.Is(err, context.Canceled)) + assert.False(t, result) + }) +} + +// errorReader always returns an error when scanning +type errorReader struct{} + +func (e errorReader) Read(p []byte) (n int, err error) { + return 0, errors.New("read error") +} + +func TestScannerError(t *testing.T) { + reader := errorReader{} + writer := &bytes.Buffer{} + manager := NewManager(reader, writer) + + ctx := context.Background() + result, err := manager.ConfirmWithDefault(ctx, "Test", false) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read input") + assert.False(t, result) +} + +func TestEOFHandling(t *testing.T) { + // Reader with no content (immediate EOF) + reader := strings.NewReader("") + writer := &bytes.Buffer{} + manager := NewManager(reader, writer) + + ctx := context.Background() + result, err := manager.ConfirmWithDefault(ctx, "Test", true) + + assert.NoError(t, err) + assert.True(t, result, "Expected default value (true) on EOF") +} diff --git a/cmd/tools/releaser/internal/fs/fs.go b/cmd/tools/releaser/internal/fs/fs.go new file mode 100644 index 00000000000..6b3af3c7d5c --- /dev/null +++ b/cmd/tools/releaser/internal/fs/fs.go @@ -0,0 +1,68 @@ +package fs + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "golang.org/x/mod/modfile" +) + +// Client implements Interface +type Client struct { + verbose bool +} + +func NewFileSystemClient(verbose bool) *Client { + return &Client{verbose: verbose} +} + +// FindGoModFiles reads go.work file and returns module directories +func (f *Client) FindGoModFiles(ctx context.Context, root string) ([]string, error) { + f.logDebug("Finding modules from go.work file") + + workFilePath := filepath.Join(root, "go.work") + modules, err := f.parseGoWorkFile(workFilePath, root) + if err != nil { + return nil, fmt.Errorf("failed to parse go.work file: %w", err) + } + + f.logDebug("Found modules from go.work: %v", modules) + return modules, nil +} + +// parseGoWorkFile parses the go.work file using the official modfile package +func (f *Client) parseGoWorkFile(workFilePath, root string) ([]string, error) { + workFileData, err := os.ReadFile(workFilePath) + if err != nil { + return nil, fmt.Errorf("failed to read go.work file: %w", err) + } + + workFile, err := modfile.ParseWork(workFilePath, workFileData, nil) + if err != nil { + return nil, fmt.Errorf("failed to parse go.work file: %w", err) + } + + modules := make([]string, 0, len(workFile.Use)) + for _, use := range workFile.Use { + modules = append(modules, use.Path) + } + + return modules, nil +} + +// resolveModulePath converts relative path to absolute path +func (f *Client) resolveModulePath(modulePath, root string) string { + if filepath.IsAbs(modulePath) { + return modulePath + } + + return filepath.Join(root, modulePath) +} + +func (f *Client) logDebug(msg string, args ...interface{}) { + if f.verbose { + fmt.Printf("%s\n", fmt.Sprintf(msg, args...)) + } +} diff --git a/cmd/tools/releaser/internal/git/git.go b/cmd/tools/releaser/internal/git/git.go new file mode 100644 index 00000000000..f9ecc32b255 --- /dev/null +++ b/cmd/tools/releaser/internal/git/git.go @@ -0,0 +1,87 @@ +package git + +import ( + "bufio" + "context" + "fmt" + "os/exec" + "strings" +) + +// Client implements Interface +type Client struct { + verbose bool +} + +func NewGitClient(verbose bool) *Client { + return &Client{verbose: verbose} +} + +func (g *Client) GetCurrentBranch(ctx context.Context) (string, error) { + g.logDebug("Getting current git branch") + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("get current branch: %w", err) + } + branch := strings.TrimSpace(string(output)) + g.logDebug("Current branch: %s", branch) + return branch, nil +} + +func (g *Client) GetTags(ctx context.Context) ([]string, error) { + g.logDebug("Fetching git tags") + cmd := exec.CommandContext(ctx, "git", "tag", "-l") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("get git tags: %w", err) + } + + var tags []string + scanner := bufio.NewScanner(strings.NewReader(string(output))) + for scanner.Scan() { + if tag := strings.TrimSpace(scanner.Text()); tag != "" { + tags = append(tags, tag) + } + } + g.logDebug("Found git tags %v", tags) + return tags, scanner.Err() +} + +func (g *Client) CreateTag(ctx context.Context, tag string) error { + g.logDebug("Creating git tag %s", tag) + cmd := exec.CommandContext(ctx, "git", "tag", tag) + err := cmd.Run() + if err != nil { + return fmt.Errorf("create tag %s: %w", tag, err) + } + return err +} + +func (g *Client) PushTag(ctx context.Context, tag string) error { + g.logDebug("Pushing git tag %s", tag) + cmd := exec.CommandContext(ctx, "git", "push", "origin", tag) + err := cmd.Run() + if err != nil { + return fmt.Errorf("push tag %s: %w", tag, err) + } + return err +} + +func (g *Client) GetRepoRoot(ctx context.Context) (string, error) { + g.logDebug("Getting repository root") + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("get repository root: %w", err) + } + root := strings.TrimSpace(string(output)) + g.logDebug("Repository root %s", root) + return root, nil +} + +func (g *Client) logDebug(msg string, args ...interface{}) { + if g.verbose { + fmt.Printf("%s\n", fmt.Sprintf(msg, args...)) + } +} diff --git a/cmd/tools/releaser/internal/release/release.go b/cmd/tools/releaser/internal/release/release.go new file mode 100644 index 00000000000..392dc7079a1 --- /dev/null +++ b/cmd/tools/releaser/internal/release/release.go @@ -0,0 +1,860 @@ +package release + +import ( + "context" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/Masterminds/semver/v3" +) + +//go:generate mockgen -package $GOPACKAGE -source $GOFILE -destination release_mocks_test.go -self_package github.com/uber/cadence/cmd/tools/releaser/release + +// Git defines git operations for testing +type Git interface { + GetCurrentBranch(ctx context.Context) (string, error) + GetTags(ctx context.Context) ([]string, error) + CreateTag(ctx context.Context, tag string) error + PushTag(ctx context.Context, tag string) error + GetRepoRoot(ctx context.Context) (string, error) +} + +// FS defines filesystem operations for testing +type FS interface { + FindGoModFiles(ctx context.Context, root string) ([]string, error) +} + +// UserInteraction defines the interface for user interactions +type UserInteraction interface { + Confirm(ctx context.Context, message string) (bool, error) + ConfirmWithDefault(ctx context.Context, message string, defaultValue bool) (bool, error) +} + +var ( + versionRegex = regexp.MustCompile(`v[0-9]+\.[0-9]+\.[0-9]+(?:-prerelease[0-9]+)?`) + prereleaseRegex = regexp.MustCompile(`v([0-9]+\.[0-9]+\.[0-9]+)-prerelease(\d+)`) +) + +// Config holds the release configuration +type Config struct { + RepoRoot string + ExcludedDirs []string + RequiredBranch string + Verbose bool + Command string // The subcommand being executed + SkipConfirmations bool + ManualVersion string // Manual version override +} + +// Manager handles the release process +type Manager struct { + config Config + git Git + fs FS + interaction UserInteraction + tagCache *TagCache +} + +func NewReleaseManager(config Config, git Git, fs FS, interaction UserInteraction) *Manager { + return &Manager{ + config: config, + git: git, + fs: fs, + interaction: interaction, + } +} + +func (rm *Manager) RunRelease(ctx context.Context) error { + return rm.executeCommand(ctx, rm.calculateReleaseVersion, rm.validateReleaseCommand) +} + +func (rm *Manager) RunPatch(ctx context.Context) error { + return rm.executeCommand(ctx, rm.calculatePatchVersion, rm.validateMinorMajorCommand) +} + +func (rm *Manager) RunMinor(ctx context.Context) error { + return rm.executeCommand(ctx, rm.calculateMinorVersion, rm.validateMinorMajorCommand) +} + +func (rm *Manager) RunMajor(ctx context.Context) error { + return rm.executeCommand(ctx, rm.calculateMajorVersion, rm.validateMinorMajorCommand) +} + +func (rm *Manager) RunPrerelease(ctx context.Context) error { + return rm.executeCommand(ctx, rm.calculatePrereleaseVersion, nil) +} + +// Generic command execution flow +func (rm *Manager) executeCommand( + ctx context.Context, + calculateVersion func(string) (string, error), + validate func(string) error, +) error { + // Initialize tag cache + if err := rm.GetKnownReleases(ctx); err != nil { + return err + } + + // Get current version + currentVersion := rm.GetCurrentGlobalVersion() + rm.logDebug("Current version: %s", currentVersion) + + var targetVersion string + var err error + + // Check for manual version override + if rm.config.ManualVersion != "" { + rm.logDebug("Using manual version override: %s", rm.config.ManualVersion) + targetVersion, err = rm.processManualVersion(rm.config.ManualVersion) + if err != nil { + return err + } + } else { + // Run validation if provided (only for automatic version calculation) + if validate != nil { + if err := validate(currentVersion); err != nil { + return err + } + } + + // Calculate target version automatically + targetVersion, err = calculateVersion(currentVersion) + if err != nil { + return err + } + } + + fmt.Printf("Version transition: %s → %s\n", currentVersion, targetVersion) + + return rm.executeRelease(ctx, targetVersion) +} + +// Version calculation methods for each subcommand +func (rm *Manager) calculateReleaseVersion(currentVersionStr string) (string, error) { + rm.logDebug("Calculating release version from: %s", currentVersionStr) + + currentVersion, err := semver.NewVersion(currentVersionStr) + if err != nil { + return "", fmt.Errorf("failed to parse current version %s: %w", currentVersionStr, err) + } + + // Remove prerelease suffix + releaseVersion := fmt.Sprintf("v%d.%d.%d", + currentVersion.Major(), + currentVersion.Minor(), + currentVersion.Patch()) + + return releaseVersion, nil +} + +// Generic version calculation that creates a closure for different version types +func (rm *Manager) calculateVersionIncrement(versionType string) func(string) (string, error) { + return func(currentVersionStr string) (string, error) { + rm.logDebug("Calculating %s version from: %s", versionType, currentVersionStr) + + // First, get the base version (remove prerelease if present) + baseVersion := rm.getBaseVersion(currentVersionStr) + + // Increment version + newVersion, err := IncrementVersion(baseVersion, versionType) + if err != nil { + return "", err + } + + // Create first prerelease + return rm.GetNextPrereleaseVersion(newVersion) + } +} + +// Update the existing methods to use the generic function +func (rm *Manager) calculateMinorVersion(currentVersionStr string) (string, error) { + return rm.calculateVersionIncrement("minor")(currentVersionStr) +} + +func (rm *Manager) calculateMajorVersion(currentVersionStr string) (string, error) { + return rm.calculateVersionIncrement("major")(currentVersionStr) +} + +func (rm *Manager) calculatePatchVersion(currentVersionStr string) (string, error) { + return rm.calculateVersionIncrement("patch")(currentVersionStr) +} + +func (rm *Manager) calculatePrereleaseVersion(currentVersionStr string) (string, error) { + rm.logDebug("Calculating prerelease version from: %s", currentVersionStr) + + currentVersion, err := semver.NewVersion(currentVersionStr) + if err != nil { + return "", fmt.Errorf("failed to parse current version %s: %w", currentVersionStr, err) + } + + baseVersionStr := fmt.Sprintf("v%d.%d.%d", + currentVersion.Major(), + currentVersion.Minor(), + currentVersion.Patch()) + + return rm.GetNextPrereleaseVersion(baseVersionStr) +} + +// Validation methods +func (rm *Manager) validateReleaseCommand(currentVersion string) error { + if !strings.Contains(currentVersion, "-prerelease") { + return fmt.Errorf("release command requires existing prerelease version, current: %s", currentVersion) + } + return nil +} + +func (rm *Manager) validateMinorMajorCommand(currentVersion string) error { + if strings.Contains(currentVersion, "-prerelease") { + return fmt.Errorf("minor/major commands should be run from stable versions, current: %s (consider using 'release' first)", currentVersion) + } + return nil +} + +// Helper method to get base version (remove prerelease suffix) +func (rm *Manager) getBaseVersion(versionStr string) string { + version, err := semver.NewVersion(versionStr) + if err != nil { + return versionStr + } + + return fmt.Sprintf("v%d.%d.%d", version.Major(), version.Minor(), version.Patch()) +} + +// processManualVersion validates and normalizes manual version input +func (rm *Manager) processManualVersion(manualVersion string) (string, error) { + // Normalize the version (ensure v prefix, validate semver) + normalizedVersion, err := NormalizeVersion(manualVersion) + if err != nil { + return "", fmt.Errorf("invalid manual version format: %w", err) + } + + rm.logDebug("Manual version normalized: %s → %s", manualVersion, normalizedVersion) + return normalizedVersion, nil +} + +// Execute the actual release +func (rm *Manager) executeRelease(ctx context.Context, targetVersion string) error { + // Assess state + state, err := rm.AssessCurrentState(ctx) + if err != nil { + return err + } + + // Plan actions + actions, warnings := rm.planReleaseActions(state, targetVersion) + + // Handle warnings and get confirmation to continue + if err = rm.handleWarningsAndConfirmations(ctx, warnings); err != nil { + return err + } + + // Check for version conflicts and handle them + conflictInfo, conflictErr := rm.CheckVersionExists(targetVersion, state.Modules) + finalActions := actions + + if conflictErr != nil { + fmt.Printf("❌ Version conflict detected:\n") + fmt.Printf(" Existing tags: %v\n", conflictInfo.ExistingTags) + fmt.Printf(" Tags to create: %v\n", conflictInfo.MissingTags) + + if len(conflictInfo.MissingTags) == 0 { + return fmt.Errorf("all tags already exist for version %s", targetVersion) + } + + if !rm.config.SkipConfirmations { + confirmed, err := rm.interaction.ConfirmWithDefault(ctx, + fmt.Sprintf("Continue and create only missing tags (%d remaining)?", len(conflictInfo.MissingTags)), + false) + if err != nil || !confirmed { + return fmt.Errorf("operation cancelled due to version conflict") + } + } + + // Filter actions to only include missing tags + finalActions = rm.filterActionsForMissingTags(actions, conflictInfo.MissingTags) + fmt.Printf("✓ Will skip existing tags and create only: %v\n", conflictInfo.MissingTags) + } + + // Show planned actions (filtered if there were conflicts) + rm.ShowPlannedActions(finalActions) + + // Confirm tag creation + if !rm.config.SkipConfirmations { + tagCount := len(finalActions) / 2 // Each tag has create + push action + message := fmt.Sprintf("Create %d tags?", tagCount) + if conflictErr != nil { + message = fmt.Sprintf("Create %d missing tags (skipping %d existing)?", len(conflictInfo.MissingTags), len(conflictInfo.ExistingTags)) + } + + confirmed, err := rm.interaction.Confirm(ctx, message) + if err != nil || !confirmed { + if ctx.Err() != nil { + return nil + } + return fmt.Errorf("tag creation cancelled") + } + } + + // Create tags (only missing ones if there were conflicts) + if err = rm.executeTagCreation(ctx, finalActions); err != nil { + return err + } + + // Confirm tag pushing + if !rm.config.SkipConfirmations { + pushCount := 0 + for _, action := range finalActions { + if action.Type == ActionPushTags { + pushCount++ + } + } + + message := fmt.Sprintf("Push %d tags?", pushCount) + confirmed, err := rm.interaction.Confirm(ctx, message) + if err != nil || !confirmed { + if ctx.Err() != nil { + return nil + } + fmt.Printf("Tags created locally but not pushed\n") + if conflictErr != nil { + fmt.Printf("Created: %v\n", conflictInfo.MissingTags) + fmt.Printf("Skipped: %v\n", conflictInfo.ExistingTags) + } + return nil + } + } + + // Push tags + if err = rm.executeTagPushing(ctx, finalActions); err != nil { + return fmt.Errorf("push tags: %w", err) + } + + // Success message + if conflictErr != nil { + fmt.Printf("✓ Release %s completed successfully\n", targetVersion) + fmt.Printf(" Created: %v\n", conflictInfo.MissingTags) + fmt.Printf(" Skipped: %v (already existed)\n", conflictInfo.ExistingTags) + } else { + fmt.Printf("✓ Release %s completed successfully\n", targetVersion) + } + + return nil +} + +// GetKnownReleases fetches and parses all tags once +func (rm *Manager) GetKnownReleases(ctx context.Context) error { + fmt.Println("Getting known releases") + + // Fetch raw tags once + rawTags, err := rm.git.GetTags(ctx) + if err != nil { + return fmt.Errorf("failed to fetch tags: %w", err) + } + + rm.tagCache = &TagCache{ + AllTags: make([]ParsedTag, 0, len(rawTags)), + VersionTags: make([]ParsedTag, 0, len(rawTags)), + ModuleTags: make(map[string][]ParsedTag), + PrereleaseCache: make(map[string][]int), + } + + for _, rawTag := range rawTags { + parsedTag := rm.parseTag(rawTag) + rm.tagCache.AllTags = append(rm.tagCache.AllTags, parsedTag) + + // Skip not version tags + if parsedTag.Version == nil { + continue + } + + rm.tagCache.VersionTags = append(rm.tagCache.VersionTags, parsedTag) + + // Group by module + if rm.tagCache.ModuleTags[parsedTag.ModulePath] == nil { + rm.tagCache.ModuleTags[parsedTag.ModulePath] = make([]ParsedTag, 0) + } + rm.tagCache.ModuleTags[parsedTag.ModulePath] = append( + rm.tagCache.ModuleTags[parsedTag.ModulePath], parsedTag) + + // Cache prerelease numbers + if parsedTag.IsPrerelease { + baseVersion := fmt.Sprintf("v%d.%d.%d", + parsedTag.Version.Major(), + parsedTag.Version.Minor(), + parsedTag.Version.Patch()) + + if rm.tagCache.PrereleaseCache[baseVersion] == nil { + rm.tagCache.PrereleaseCache[baseVersion] = make([]int, 0) + } + rm.tagCache.PrereleaseCache[baseVersion] = append( + rm.tagCache.PrereleaseCache[baseVersion], parsedTag.PrereleaseNum) + } + } + + // Sort and cache highest version + if len(rm.tagCache.VersionTags) > 0 { + rm.sortVersionTags() + rm.tagCache.HighestVersion = rm.tagCache.VersionTags[len(rm.tagCache.VersionTags)-1].Version + } + + rm.logDebug("Known releases total tags (%d), version_tags(%d)", len(rm.tagCache.AllTags), len(rm.tagCache.VersionTags)) + + return nil +} + +func (rm *Manager) parseTag(rawTag string) ParsedTag { + parsed := ParsedTag{Raw: rawTag} + + // Extract module path and version part + if idx := strings.LastIndex(rawTag, "/v"); idx != -1 { + parsed.ModulePath = rawTag[:idx] + versionPart := rawTag[idx+1:] + + if versionRegex.MatchString(versionPart) { + if version, err := semver.NewVersion(versionPart); err == nil { + parsed.Version = version + + // Check for prerelease + if matches := prereleaseRegex.FindStringSubmatch(versionPart); len(matches) > 2 { + parsed.IsPrerelease = true + if num, err := strconv.Atoi(matches[2]); err == nil { + parsed.PrereleaseNum = num + } + } + } + } + return parsed + } + + // Root module tag + if versionRegex.MatchString(rawTag) { + if version, err := semver.NewVersion(rawTag); err == nil { + parsed.Version = version + + if matches := prereleaseRegex.FindStringSubmatch(rawTag); len(matches) > 2 { + parsed.IsPrerelease = true + if num, err := strconv.Atoi(matches[2]); err == nil { + parsed.PrereleaseNum = num + } + } + } + } + + return parsed +} + +func (rm *Manager) sortVersionTags() { + sort.Slice(rm.tagCache.VersionTags, func(i, j int) bool { + return rm.tagCache.VersionTags[i].Version.LessThan(rm.tagCache.VersionTags[j].Version) + }) +} + +func (rm *Manager) GetCurrentGlobalVersion() string { + if rm.tagCache.HighestVersion == nil { + return "v0.0.0" + } + + return "v" + rm.tagCache.HighestVersion.String() +} + +func (rm *Manager) GetNextPrereleaseVersion(baseVersionStr string) (string, error) { + // Parse base version + baseVersion, err := semver.NewVersion(baseVersionStr) + if err != nil { + return "", fmt.Errorf("failed to parse base version %s: %w", baseVersionStr, err) + } + + cleanBaseStr := fmt.Sprintf("v%d.%d.%d", baseVersion.Major(), baseVersion.Minor(), baseVersion.Patch()) + + prereleaseNumbers := rm.tagCache.PrereleaseCache[cleanBaseStr] + if len(prereleaseNumbers) == 0 { + return fmt.Sprintf("%s-prerelease01", cleanBaseStr), nil + } + + // Sort and get next number + sort.Ints(prereleaseNumbers) + nextNum := prereleaseNumbers[len(prereleaseNumbers)-1] + 1 + + if nextNum > 99 { + return "", fmt.Errorf("maximum prerelease number (99) exceeded, base (%s)", cleanBaseStr) + } + + return fmt.Sprintf("%s-prerelease%02d", cleanBaseStr, nextNum), nil +} + +func (rm *Manager) CheckVersionExists(version string, modules []Module) (VersionConflictInfo, error) { + conflictInfo := VersionConflictInfo{ + ExistingTags: make([]string, 0), + MissingTags: make([]string, 0), + } + + for _, module := range modules { + expectedTag := version + if module.Path != "" { + expectedTag = module.Path + "/" + version + } + + exists := false + for _, tag := range rm.tagCache.AllTags { + if tag.Raw == expectedTag { + conflictInfo.ExistingTags = append(conflictInfo.ExistingTags, expectedTag) + exists = true + break + } + } + + if !exists { + conflictInfo.MissingTags = append(conflictInfo.MissingTags, expectedTag) + } + } + + if len(conflictInfo.ExistingTags) > 0 { + return conflictInfo, fmt.Errorf("some tags already exist: %v", conflictInfo.ExistingTags) + } + + return conflictInfo, nil +} + +// AssessCurrentState gathers repository state (assumes cache is already populated) +func (rm *Manager) AssessCurrentState(ctx context.Context) (*State, error) { + state := &State{} + + var err error + // Gather information (cache should already be populated) + state.CurrentBranch, err = rm.git.GetCurrentBranch(ctx) + if err != nil { + return nil, fmt.Errorf("get current branch: %w", err) + } + state.Modules, err = rm.FindModules(ctx) + if err != nil { + return nil, fmt.Errorf("find modules: %w", err) + } + state.CurrentVersion = rm.GetCurrentGlobalVersion() + state.TagCache = rm.tagCache + + return state, nil +} + +// ShowCurrentState displays current release state +func (rm *Manager) ShowCurrentState(ctx context.Context) error { + // Initialize tag cache + if err := rm.GetKnownReleases(ctx); err != nil { + return err + } + + state, err := rm.AssessCurrentState(ctx) + if err != nil { + return err + } + + fmt.Printf("Repository Release Status\n") + fmt.Printf("========================\n") + fmt.Printf("Branch: %s\n", state.CurrentBranch) + fmt.Printf("Global Version: %s\n", state.CurrentVersion) + fmt.Printf("\n") + + fmt.Printf("Modules and Versions:\n") + for _, module := range state.Modules { + moduleName := module.Path + if moduleName == "" { + moduleName = "root" + } + fmt.Printf(" %-20s %s\n", moduleName, module.Version) + } + + // Show what commands are available + fmt.Printf("\nAvailable Commands:\n") + if strings.Contains(state.CurrentVersion, "-prerelease") { + fmt.Printf(" releaser prerelease # Increment prerelease number\n") + fmt.Printf(" releaser release # Promote to final release\n") + fmt.Printf(" releaser release -s v1.4.0 # Override with specific version\n") + } else { + fmt.Printf(" releaser minor # Start new minor version cycle\n") + fmt.Printf(" releaser major # Start new major version cycle\n") + fmt.Printf(" releaser patch # Start new patch version cycle\n") + fmt.Printf(" releaser minor -s v1.4.0-prerelease01 # Override with specific version\n") + } + + return nil +} + +// ShowPlannedActions displays what actions will be performed +func (rm *Manager) ShowPlannedActions(actions []Action) { + if len(actions) == 0 { + fmt.Println("No actions planned") + return + } + + fmt.Println("\nPlanned Release Actions:") + + // Group actions by type for better readability + createActions := make([]Action, 0) + pushActions := make([]Action, 0) + + for _, action := range actions { + switch action.Type { + case ActionCreateTag: + createActions = append(createActions, action) + case ActionPushTags: + pushActions = append(pushActions, action) + } + } + + if len(createActions) > 0 { + fmt.Println("\nCreate Tags:") + for _, action := range createActions { + fmt.Printf(" git tag %s\n", action.Target) + } + } + + if len(pushActions) > 0 { + fmt.Println("\nPush Tags:") + for _, action := range pushActions { + fmt.Printf(" git push origin %s\n", action.Target) + } + } +} + +func (rm *Manager) planReleaseActions(state *State, targetVersion string) ([]Action, []Warning) { + var actions []Action + var warnings []Warning + + // Add actions for each module + for _, module := range state.Modules { + tagName := rm.getTagName(module, targetVersion) + + actions = append(actions, Action{ + Type: ActionCreateTag, + Target: tagName, + Description: fmt.Sprintf("Create tag %s", tagName), + }) + + actions = append(actions, Action{ + Type: ActionPushTags, + Target: tagName, + Description: fmt.Sprintf("Push tag %s", tagName), + }) + } + + // Add warnings + warnings = append(warnings, rm.validateWithWarnings(state, targetVersion)...) + + return actions, warnings +} + +func (rm *Manager) validateWithWarnings(state *State, targetVersion string) []Warning { + var warnings []Warning + + // Branch check -> warning + if rm.config.RequiredBranch != "" && state.CurrentBranch != rm.config.RequiredBranch { + warnings = append(warnings, Warning{ + Type: WrongBranch, + Message: fmt.Sprintf("you are not on %s", rm.config.RequiredBranch), + }) + } + + return warnings +} + +// filterActionsForMissingTags filters actions to only include missing tags +func (rm *Manager) filterActionsForMissingTags(actions []Action, missingTags []string) []Action { + var filteredActions []Action + + // Create a set of missing tags for quick lookup + missingTagSet := make(map[string]bool) + for _, tag := range missingTags { + missingTagSet[tag] = true + } + + // Filter actions to only include missing tags + for _, action := range actions { + if missingTagSet[action.Target] { + filteredActions = append(filteredActions, action) + } + } + + return filteredActions +} + +// getLatestVersionForModule returns the latest version for a given module path +func (rm *Manager) getLatestVersionForModule(modulePath string) string { + // Handle case where tag cache isn't initialized yet + if rm.tagCache == nil || rm.tagCache.ModuleTags == nil { + return "v0.0.0" // Default for modules with no releases yet + } + + moduleTags, exists := rm.tagCache.ModuleTags[modulePath] + if !exists || len(moduleTags) == 0 { + return "v0.0.0" // No releases for this module yet + } + + // Find the latest version among all tags for this module + var latestVersion *semver.Version + for _, tag := range moduleTags { + if tag.Version != nil { + if latestVersion == nil || tag.Version.GreaterThan(latestVersion) { + latestVersion = tag.Version + } + } + } + + if latestVersion == nil { + return "v0.0.0" + } + + return "v" + latestVersion.String() +} + +func (rm *Manager) handleWarningsAndConfirmations(ctx context.Context, warnings []Warning) error { + for _, warning := range warnings { + fmt.Printf("⚠️ %s\n", warning.Message) + + if rm.config.SkipConfirmations { + continue + } + + confirmed, err := rm.interaction.ConfirmWithDefault(ctx, "Continue?", false) + if err != nil { + return err + } + if !confirmed { + return fmt.Errorf("operation cancelled due to: %s", warning.Message) + } + } + return nil +} + +func (rm *Manager) executeTagCreation(ctx context.Context, actions []Action) error { + fmt.Println("Creating tags...") + for _, action := range actions { + if action.Type == ActionCreateTag { + if err := rm.git.CreateTag(ctx, action.Target); err != nil { + return fmt.Errorf("failed to create tag %s: %w", action.Target, err) + } + fmt.Printf("✓ Created tag %s\n", action.Target) + } + } + return nil +} + +func (rm *Manager) executeTagPushing(ctx context.Context, actions []Action) error { + fmt.Println("Pushing tags...") + for _, action := range actions { + if action.Type == ActionPushTags { + if err := rm.git.PushTag(ctx, action.Target); err != nil { + return fmt.Errorf("failed to push tag %s: %w", action.Target, err) + } + fmt.Printf("✓ Pushed tag %s\n", action.Target) + } + } + return nil +} + +// NormalizeVersion ensures version has 'v' prefix and is valid semver +func NormalizeVersion(v string) (string, error) { + if !strings.HasPrefix(v, "v") { + v = "v" + v + } + + // Parse with Masterminds/semver to validate + semVer, err := semver.NewVersion(v) + if err != nil { + return "", fmt.Errorf("invalid semantic version: %s", v) + } + + return "v" + semVer.String(), nil +} + +// IncrementVersion increments a version based on type +func IncrementVersion(currentVersionStr, versionType string) (string, error) { + // Parse the current version + currentVersion, err := semver.NewVersion(currentVersionStr) + if err != nil { + return "", fmt.Errorf("failed to parse current version %s: %w", currentVersionStr, err) + } + + var newVersion semver.Version + switch versionType { + case "major": + newVersion = currentVersion.IncMajor() + case "minor": + newVersion = currentVersion.IncMinor() + case "patch": + newVersion = currentVersion.IncPatch() + default: + return "", fmt.Errorf("invalid version type: %s", versionType) + } + + return "v" + newVersion.String(), nil +} + +// FindModules discovers all Go modules in the repository +func (rm *Manager) FindModules(ctx context.Context) ([]Module, error) { + rm.logDebug("Discovering Go modules in path %s", rm.config.RepoRoot) + goModPaths, err := rm.fs.FindGoModFiles(ctx, rm.config.RepoRoot) + if err != nil { + return nil, fmt.Errorf("failed to find go.mod files: %w", err) + } + + var modules []Module + seen := make(map[string]bool) + + for _, path := range goModPaths { + + // Normalize relative path + if path == "." { + path = "" + } + path = strings.TrimPrefix(path, "./") + + // Check if should be excluded + if rm.shouldExcludeModule(path) { + rm.logDebug("Excluding module %s", path) + continue + } + + // Deduplicate + if seen[path] { + continue + } + seen[path] = true + + // Get latest version for this module from cache + latestVersion := rm.getLatestVersionForModule(path) + + modules = append(modules, Module{ + Path: path, + Version: latestVersion, + }) + rm.logDebug("Found module\n%s\nversion: %s\n", path, latestVersion) + } + + return modules, nil +} + +// shouldExcludeModule checks if a module should be excluded +func (rm *Manager) shouldExcludeModule(relPath string) bool { + for _, excluded := range rm.config.ExcludedDirs { + if relPath == excluded || strings.HasPrefix(relPath, excluded+"/") { + return true + } + } + return false +} + +// getTagName generates the tag name for a module and version +func (rm *Manager) getTagName(module Module, version string) string { + if module.Path == "" { + return version + } + return module.Path + "/" + version +} + +func (rm *Manager) logDebug(msg string, args ...interface{}) { + if rm.config.Verbose { + fmt.Printf("DEBUG: %s\n", fmt.Sprintf(msg, args...)) + } +} diff --git a/cmd/tools/releaser/internal/release/release_mocks_test.go b/cmd/tools/releaser/internal/release/release_mocks_test.go new file mode 100644 index 00000000000..969e70a11ab --- /dev/null +++ b/cmd/tools/releaser/internal/release/release_mocks_test.go @@ -0,0 +1,207 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: release.go +// +// Generated by this command: +// +// mockgen -package release -source release.go -destination release_mocks_test.go -self_package github.com/uber/cadence/cmd/tools/releaser/release +// + +// Package release is a generated GoMock package. +package release + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockGit is a mock of Git interface. +type MockGit struct { + ctrl *gomock.Controller + recorder *MockGitMockRecorder + isgomock struct{} +} + +// MockGitMockRecorder is the mock recorder for MockGit. +type MockGitMockRecorder struct { + mock *MockGit +} + +// NewMockGit creates a new mock instance. +func NewMockGit(ctrl *gomock.Controller) *MockGit { + mock := &MockGit{ctrl: ctrl} + mock.recorder = &MockGitMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGit) EXPECT() *MockGitMockRecorder { + return m.recorder +} + +// CreateTag mocks base method. +func (m *MockGit) CreateTag(ctx context.Context, tag string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTag", ctx, tag) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateTag indicates an expected call of CreateTag. +func (mr *MockGitMockRecorder) CreateTag(ctx, tag any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTag", reflect.TypeOf((*MockGit)(nil).CreateTag), ctx, tag) +} + +// GetCurrentBranch mocks base method. +func (m *MockGit) GetCurrentBranch(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCurrentBranch", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCurrentBranch indicates an expected call of GetCurrentBranch. +func (mr *MockGitMockRecorder) GetCurrentBranch(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCurrentBranch", reflect.TypeOf((*MockGit)(nil).GetCurrentBranch), ctx) +} + +// GetRepoRoot mocks base method. +func (m *MockGit) GetRepoRoot(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRepoRoot", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRepoRoot indicates an expected call of GetRepoRoot. +func (mr *MockGitMockRecorder) GetRepoRoot(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRepoRoot", reflect.TypeOf((*MockGit)(nil).GetRepoRoot), ctx) +} + +// GetTags mocks base method. +func (m *MockGit) GetTags(ctx context.Context) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTags", ctx) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTags indicates an expected call of GetTags. +func (mr *MockGitMockRecorder) GetTags(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTags", reflect.TypeOf((*MockGit)(nil).GetTags), ctx) +} + +// PushTag mocks base method. +func (m *MockGit) PushTag(ctx context.Context, tag string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PushTag", ctx, tag) + ret0, _ := ret[0].(error) + return ret0 +} + +// PushTag indicates an expected call of PushTag. +func (mr *MockGitMockRecorder) PushTag(ctx, tag any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PushTag", reflect.TypeOf((*MockGit)(nil).PushTag), ctx, tag) +} + +// MockFS is a mock of FS interface. +type MockFS struct { + ctrl *gomock.Controller + recorder *MockFSMockRecorder + isgomock struct{} +} + +// MockFSMockRecorder is the mock recorder for MockFS. +type MockFSMockRecorder struct { + mock *MockFS +} + +// NewMockFS creates a new mock instance. +func NewMockFS(ctrl *gomock.Controller) *MockFS { + mock := &MockFS{ctrl: ctrl} + mock.recorder = &MockFSMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFS) EXPECT() *MockFSMockRecorder { + return m.recorder +} + +// FindGoModFiles mocks base method. +func (m *MockFS) FindGoModFiles(ctx context.Context, root string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindGoModFiles", ctx, root) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindGoModFiles indicates an expected call of FindGoModFiles. +func (mr *MockFSMockRecorder) FindGoModFiles(ctx, root any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindGoModFiles", reflect.TypeOf((*MockFS)(nil).FindGoModFiles), ctx, root) +} + +// MockUserInteraction is a mock of UserInteraction interface. +type MockUserInteraction struct { + ctrl *gomock.Controller + recorder *MockUserInteractionMockRecorder + isgomock struct{} +} + +// MockUserInteractionMockRecorder is the mock recorder for MockUserInteraction. +type MockUserInteractionMockRecorder struct { + mock *MockUserInteraction +} + +// NewMockUserInteraction creates a new mock instance. +func NewMockUserInteraction(ctrl *gomock.Controller) *MockUserInteraction { + mock := &MockUserInteraction{ctrl: ctrl} + mock.recorder = &MockUserInteractionMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockUserInteraction) EXPECT() *MockUserInteractionMockRecorder { + return m.recorder +} + +// Confirm mocks base method. +func (m *MockUserInteraction) Confirm(ctx context.Context, message string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Confirm", ctx, message) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Confirm indicates an expected call of Confirm. +func (mr *MockUserInteractionMockRecorder) Confirm(ctx, message any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Confirm", reflect.TypeOf((*MockUserInteraction)(nil).Confirm), ctx, message) +} + +// ConfirmWithDefault mocks base method. +func (m *MockUserInteraction) ConfirmWithDefault(ctx context.Context, message string, defaultValue bool) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConfirmWithDefault", ctx, message, defaultValue) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ConfirmWithDefault indicates an expected call of ConfirmWithDefault. +func (mr *MockUserInteractionMockRecorder) ConfirmWithDefault(ctx, message, defaultValue any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConfirmWithDefault", reflect.TypeOf((*MockUserInteraction)(nil).ConfirmWithDefault), ctx, message, defaultValue) +} diff --git a/cmd/tools/releaser/internal/release/release_test.go b/cmd/tools/releaser/internal/release/release_test.go new file mode 100644 index 00000000000..e1db3db1c2e --- /dev/null +++ b/cmd/tools/releaser/internal/release/release_test.go @@ -0,0 +1,615 @@ +package release + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestManager_RunRelease(t *testing.T) { + tests := []struct { + name string + currentTags []string + expectedTarget string + shouldError bool + errorMsg string + }{ + { + name: "successful release from prerelease", + currentTags: []string{"v1.2.3-prerelease01"}, + expectedTarget: "v1.2.3", + shouldError: false, + }, + { + name: "error when no prerelease exists", + currentTags: []string{"v1.2.3"}, + shouldError: true, + errorMsg: "release command requires existing prerelease version", + }, + { + name: "release from higher prerelease number", + currentTags: []string{"v1.2.3-prerelease05"}, + expectedTarget: "v1.2.3", + shouldError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGit := NewMockGit(ctrl) + mockFS := NewMockFS(ctrl) + mockInteraction := NewMockUserInteraction(ctrl) + + config := Config{ + RepoRoot: "/test", + ExcludedDirs: []string{}, + RequiredBranch: "master", + Verbose: false, + Command: "release", + SkipConfirmations: true, // Skip confirmations for testing + } + + manager := NewReleaseManager(config, mockGit, mockFS, mockInteraction) + + // Setup mocks + mockGit.EXPECT().GetTags(gomock.Any()).Return(tt.currentTags, nil) + + if !tt.shouldError { + mockGit.EXPECT().GetCurrentBranch(gomock.Any()).Return("master", nil) + mockFS.EXPECT().FindGoModFiles(gomock.Any(), "/test").Return([]string{"."}, nil) + mockGit.EXPECT().CreateTag(gomock.Any(), tt.expectedTarget).Return(nil) + mockGit.EXPECT().PushTag(gomock.Any(), tt.expectedTarget).Return(nil) + } + + err := manager.RunRelease(context.Background()) + + if tt.shouldError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestManager_RunMinor(t *testing.T) { + tests := []struct { + name string + currentTags []string + expectedTarget string + shouldError bool + errorMsg string + }{ + { + name: "successful minor from stable", + currentTags: []string{"v1.2.3"}, + expectedTarget: "v1.3.0-prerelease01", + shouldError: false, + }, + { + name: "error when current is prerelease", + currentTags: []string{"v1.2.3-prerelease01"}, + shouldError: true, + errorMsg: "minor/major commands should be run from stable versions", + }, + { + name: "minor from initial version", + currentTags: []string{}, + expectedTarget: "v0.1.0-prerelease01", + shouldError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGit := NewMockGit(ctrl) + mockFS := NewMockFS(ctrl) + mockInteraction := NewMockUserInteraction(ctrl) + + config := Config{ + RepoRoot: "/test", + ExcludedDirs: []string{}, + RequiredBranch: "master", + Verbose: false, + Command: "minor", + SkipConfirmations: true, + } + + manager := NewReleaseManager(config, mockGit, mockFS, mockInteraction) + + // Setup mocks + mockGit.EXPECT().GetTags(gomock.Any()).Return(tt.currentTags, nil) + + if !tt.shouldError { + mockGit.EXPECT().GetCurrentBranch(gomock.Any()).Return("master", nil) + mockFS.EXPECT().FindGoModFiles(gomock.Any(), "/test").Return([]string{"."}, nil) + mockGit.EXPECT().CreateTag(gomock.Any(), tt.expectedTarget).Return(nil) + mockGit.EXPECT().PushTag(gomock.Any(), tt.expectedTarget).Return(nil) + } + + err := manager.RunMinor(context.Background()) + + if tt.shouldError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestManager_RunMajor(t *testing.T) { + tests := []struct { + name string + currentTags []string + expectedTarget string + shouldError bool + }{ + { + name: "successful major from stable", + currentTags: []string{"v1.2.3"}, + expectedTarget: "v2.0.0-prerelease01", + shouldError: false, + }, + { + name: "major from initial version", + currentTags: []string{}, + expectedTarget: "v1.0.0-prerelease01", + shouldError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGit := NewMockGit(ctrl) + mockFS := NewMockFS(ctrl) + mockInteraction := NewMockUserInteraction(ctrl) + + config := Config{ + RepoRoot: "/test", + ExcludedDirs: []string{}, + RequiredBranch: "master", + Verbose: false, + Command: "major", + SkipConfirmations: true, + } + + manager := NewReleaseManager(config, mockGit, mockFS, mockInteraction) + + // Setup mocks + mockGit.EXPECT().GetTags(gomock.Any()).Return(tt.currentTags, nil) + mockGit.EXPECT().GetCurrentBranch(gomock.Any()).Return("master", nil) + mockFS.EXPECT().FindGoModFiles(gomock.Any(), "/test").Return([]string{"."}, nil) + mockGit.EXPECT().CreateTag(gomock.Any(), tt.expectedTarget).Return(nil) + mockGit.EXPECT().PushTag(gomock.Any(), tt.expectedTarget).Return(nil) + + err := manager.RunMajor(context.Background()) + require.NoError(t, err) + }) + } +} + +func TestManager_RunPrerelease(t *testing.T) { + tests := []struct { + name string + currentTags []string + expectedTarget string + }{ + { + name: "increment prerelease number", + currentTags: []string{"v1.2.3-prerelease01"}, + expectedTarget: "v1.2.3-prerelease02", + }, + { + name: "create first prerelease from stable", + currentTags: []string{"v1.2.3"}, + expectedTarget: "v1.2.3-prerelease01", + }, + { + name: "increment from higher prerelease", + currentTags: []string{"v1.2.3-prerelease05"}, + expectedTarget: "v1.2.3-prerelease06", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGit := NewMockGit(ctrl) + mockFS := NewMockFS(ctrl) + mockInteraction := NewMockUserInteraction(ctrl) + + config := Config{ + RepoRoot: "/test", + ExcludedDirs: []string{}, + RequiredBranch: "master", + Verbose: false, + Command: "prerelease", + SkipConfirmations: true, + } + + manager := NewReleaseManager(config, mockGit, mockFS, mockInteraction) + + // Setup mocks + mockGit.EXPECT().GetTags(gomock.Any()).Return(tt.currentTags, nil) + mockGit.EXPECT().GetCurrentBranch(gomock.Any()).Return("master", nil) + mockFS.EXPECT().FindGoModFiles(gomock.Any(), "/test").Return([]string{"."}, nil) + mockGit.EXPECT().CreateTag(gomock.Any(), tt.expectedTarget).Return(nil) + mockGit.EXPECT().PushTag(gomock.Any(), tt.expectedTarget).Return(nil) + + err := manager.RunPrerelease(context.Background()) + require.NoError(t, err) + }) + } +} + +func TestManager_ManualVersionOverride(t *testing.T) { + tests := []struct { + name string + manualVersion string + currentTags []string + expectedTarget string + shouldError bool + errorMsg string + }{ + { + name: "valid manual version", + manualVersion: "v2.5.0", + currentTags: []string{"v1.2.3"}, + expectedTarget: "v2.5.0", + shouldError: false, + }, + { + name: "manual version without v prefix", + manualVersion: "2.5.0", + currentTags: []string{"v1.2.3"}, + expectedTarget: "v2.5.0", + shouldError: false, + }, + { + name: "invalid manual version", + manualVersion: "invalid", + currentTags: []string{"v1.2.3"}, + shouldError: true, + errorMsg: "invalid manual version format", + }, + { + name: "manual prerelease version", + manualVersion: "v2.5.0-prerelease03", + currentTags: []string{"v1.2.3"}, + expectedTarget: "v2.5.0-prerelease03", + shouldError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGit := NewMockGit(ctrl) + mockFS := NewMockFS(ctrl) + mockInteraction := NewMockUserInteraction(ctrl) + + config := Config{ + RepoRoot: "/test", + ExcludedDirs: []string{}, + RequiredBranch: "master", + Verbose: false, + Command: "release", + SkipConfirmations: true, + ManualVersion: tt.manualVersion, + } + + manager := NewReleaseManager(config, mockGit, mockFS, mockInteraction) + + // Setup mocks + mockGit.EXPECT().GetTags(gomock.Any()).Return(tt.currentTags, nil) + + if !tt.shouldError { + mockGit.EXPECT().GetCurrentBranch(gomock.Any()).Return("master", nil) + mockFS.EXPECT().FindGoModFiles(gomock.Any(), "/test").Return([]string{"."}, nil) + mockGit.EXPECT().CreateTag(gomock.Any(), tt.expectedTarget).Return(nil) + mockGit.EXPECT().PushTag(gomock.Any(), tt.expectedTarget).Return(nil) + } + + err := manager.RunRelease(context.Background()) + + if tt.shouldError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestManager_ConflictResolution(t *testing.T) { + tests := []struct { + name string + targetVersion string + existingTags []string + modules []Module + expectedCreate []string + expectedSkip []string + shouldError bool + }{ + { + name: "partial conflict - some modules have version", + targetVersion: "v1.3.0", + existingTags: []string{"moduleA/v1.3.0"}, + modules: []Module{ + {Path: "moduleA", Version: "v1.2.0"}, + {Path: "moduleB", Version: "v1.2.0"}, + {Path: "", Version: "v1.2.0"}, // root module + }, + expectedCreate: []string{"moduleB/v1.3.0", "v1.3.0"}, + expectedSkip: []string{"moduleA/v1.3.0"}, + shouldError: false, + }, + { + name: "complete conflict - all modules have version", + targetVersion: "v1.3.0", + existingTags: []string{"moduleA/v1.3.0", "moduleB/v1.3.0", "v1.3.0"}, + modules: []Module{ + {Path: "moduleA", Version: "v1.2.0"}, + {Path: "moduleB", Version: "v1.2.0"}, + {Path: "", Version: "v1.2.0"}, + }, + expectedCreate: []string{}, + expectedSkip: []string{"moduleA/v1.3.0", "moduleB/v1.3.0", "v1.3.0"}, + shouldError: true, + }, + { + name: "no conflict", + targetVersion: "v1.3.0", + existingTags: []string{}, + modules: []Module{ + {Path: "moduleA", Version: "v1.2.0"}, + {Path: "moduleB", Version: "v1.2.0"}, + }, + expectedCreate: []string{"moduleA/v1.3.0", "moduleB/v1.3.0"}, + expectedSkip: []string{}, + shouldError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGit := NewMockGit(ctrl) + mockFS := NewMockFS(ctrl) + mockInteraction := NewMockUserInteraction(ctrl) + + config := Config{ + RepoRoot: "/test", + ExcludedDirs: []string{}, + RequiredBranch: "master", + Verbose: false, + Command: "release", + SkipConfirmations: true, + ManualVersion: tt.targetVersion, + } + + manager := NewReleaseManager(config, mockGit, mockFS, mockInteraction) + + // Setup tag cache + manager.tagCache = &TagCache{ + AllTags: make([]ParsedTag, 0), + } + + // Parse existing tags + for _, tag := range tt.existingTags { + manager.tagCache.AllTags = append(manager.tagCache.AllTags, ParsedTag{Raw: tag}) + } + + conflictInfo, err := manager.CheckVersionExists(tt.targetVersion, tt.modules) + + if tt.shouldError && len(tt.expectedCreate) == 0 { + require.Error(t, err) + assert.ElementsMatch(t, tt.expectedSkip, conflictInfo.ExistingTags) + assert.Empty(t, conflictInfo.MissingTags) + } else { + if len(tt.expectedSkip) > 0 { + require.Error(t, err) // Should have conflict error + assert.ElementsMatch(t, tt.expectedSkip, conflictInfo.ExistingTags) + } else { + require.NoError(t, err) // No conflict + } + assert.ElementsMatch(t, tt.expectedCreate, conflictInfo.MissingTags) + } + }) + } +} + +func TestManager_MultiModuleOperations(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGit := NewMockGit(ctrl) + mockFS := NewMockFS(ctrl) + mockInteraction := NewMockUserInteraction(ctrl) + + config := Config{ + RepoRoot: "/test", + ExcludedDirs: []string{"cmd", "internal/tools"}, + RequiredBranch: "master", + Verbose: false, + Command: "minor", + SkipConfirmations: true, + } + + manager := NewReleaseManager(config, mockGit, mockFS, mockInteraction) + + // Setup mocks for multi-module repo + currentTags := []string{ + "v1.2.3", + "service1/v1.2.3", + "service2/v1.2.3", + } + goModPaths := []string{".", "service1", "service2", "cmd/tool", "internal/tools/helper"} + + expectedTags := []string{ + "v1.3.0-prerelease01", + "service1/v1.3.0-prerelease01", + "service2/v1.3.0-prerelease01", + } + + mockGit.EXPECT().GetTags(gomock.Any()).Return(currentTags, nil) + mockGit.EXPECT().GetCurrentBranch(gomock.Any()).Return("master", nil) + mockFS.EXPECT().FindGoModFiles(gomock.Any(), "/test").Return(goModPaths, nil) + + // Expect tag creation for non-excluded modules only + for _, tag := range expectedTags { + mockGit.EXPECT().CreateTag(gomock.Any(), tag).Return(nil) + mockGit.EXPECT().PushTag(gomock.Any(), tag).Return(nil) + } + + err := manager.RunMinor(context.Background()) + require.NoError(t, err) +} + +func TestManager_ErrorHandling(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockGit, *MockFS, *MockUserInteraction) + expectedErr string + }{ + { + name: "git tags fetch error", + setupMock: func(mockGit *MockGit, mockFS *MockFS, mockInteraction *MockUserInteraction) { + mockGit.EXPECT().GetTags(gomock.Any()).Return(nil, errors.New("git error")) + }, + expectedErr: "failed to fetch tags", + }, + { + name: "git branch fetch error", + setupMock: func(mockGit *MockGit, mockFS *MockFS, mockInteraction *MockUserInteraction) { + mockGit.EXPECT().GetTags(gomock.Any()).Return([]string{"v1.2.3"}, nil) + mockGit.EXPECT().GetCurrentBranch(gomock.Any()).Return("", errors.New("branch error")) + }, + expectedErr: "get current branch", + }, + { + name: "fs find modules error", + setupMock: func(mockGit *MockGit, mockFS *MockFS, mockInteraction *MockUserInteraction) { + mockGit.EXPECT().GetTags(gomock.Any()).Return([]string{"v1.2.3"}, nil) + mockGit.EXPECT().GetCurrentBranch(gomock.Any()).Return("master", nil) + mockFS.EXPECT().FindGoModFiles(gomock.Any(), "/test").Return(nil, errors.New("fs error")) + }, + expectedErr: "find modules", + }, + { + name: "tag creation error", + setupMock: func(mockGit *MockGit, mockFS *MockFS, mockInteraction *MockUserInteraction) { + mockGit.EXPECT().GetTags(gomock.Any()).Return([]string{"v1.2.3"}, nil) + mockGit.EXPECT().GetCurrentBranch(gomock.Any()).Return("master", nil) + mockFS.EXPECT().FindGoModFiles(gomock.Any(), "/test").Return([]string{"."}, nil) + mockGit.EXPECT().CreateTag(gomock.Any(), "v1.3.0-prerelease01").Return(errors.New("tag error")) + }, + expectedErr: "failed to create tag", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGit := NewMockGit(ctrl) + mockFS := NewMockFS(ctrl) + mockInteraction := NewMockUserInteraction(ctrl) + + config := Config{ + RepoRoot: "/test", + ExcludedDirs: []string{}, + RequiredBranch: "master", + Verbose: false, + Command: "minor", + SkipConfirmations: true, + } + + manager := NewReleaseManager(config, mockGit, mockFS, mockInteraction) + + tt.setupMock(mockGit, mockFS, mockInteraction) + + err := manager.RunMinor(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + }) + } +} + +func TestManager_RunPatch(t *testing.T) { + tests := []struct { + name string + currentTags []string + expectedTarget string + shouldError bool + }{ + { + name: "successful patch from stable", + currentTags: []string{"v1.2.3"}, + expectedTarget: "v1.2.4-prerelease01", + shouldError: false, + }, + { + name: "patch from initial version", + currentTags: []string{}, + expectedTarget: "v0.0.1-prerelease01", + shouldError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGit := NewMockGit(ctrl) + mockFS := NewMockFS(ctrl) + mockInteraction := NewMockUserInteraction(ctrl) + + config := Config{ + RepoRoot: "/test", + ExcludedDirs: []string{}, + RequiredBranch: "master", + Verbose: false, + Command: "patch", + SkipConfirmations: true, + } + + manager := NewReleaseManager(config, mockGit, mockFS, mockInteraction) + + // Setup mocks + mockGit.EXPECT().GetTags(gomock.Any()).Return(tt.currentTags, nil) + mockGit.EXPECT().GetCurrentBranch(gomock.Any()).Return("master", nil) + mockFS.EXPECT().FindGoModFiles(gomock.Any(), "/test").Return([]string{"."}, nil) + mockGit.EXPECT().CreateTag(gomock.Any(), tt.expectedTarget).Return(nil) + mockGit.EXPECT().PushTag(gomock.Any(), tt.expectedTarget).Return(nil) + + err := manager.RunPatch(context.Background()) + require.NoError(t, err) + }) + } +} diff --git a/cmd/tools/releaser/internal/release/scenario_test.go b/cmd/tools/releaser/internal/release/scenario_test.go new file mode 100644 index 00000000000..363e091362d --- /dev/null +++ b/cmd/tools/releaser/internal/release/scenario_test.go @@ -0,0 +1,540 @@ +package release + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestCompleteReleaseWorkflow(t *testing.T) { + tests := []struct { + name string + workflow []workflowStep + description string + }{ + { + name: "feature development cycle", + description: "Complete feature development from minor through prerelease iterations to final release", + workflow: []workflowStep{ + { + command: "minor", + currentTags: []string{"v1.2.3"}, + expectedTag: "v1.3.0-prerelease01", + expectedError: false, + }, + { + command: "prerelease", + currentTags: []string{"v1.2.3", "v1.3.0-prerelease01"}, + expectedTag: "v1.3.0-prerelease02", + expectedError: false, + }, + { + command: "prerelease", + currentTags: []string{"v1.2.3", "v1.3.0-prerelease01", "v1.3.0-prerelease02"}, + expectedTag: "v1.3.0-prerelease03", + expectedError: false, + }, + { + command: "release", + currentTags: []string{"v1.2.3", "v1.3.0-prerelease01", "v1.3.0-prerelease02", "v1.3.0-prerelease03"}, + expectedTag: "v1.3.0", + expectedError: false, + }, + }, + }, + { + name: "major version cycle", + description: "Major version development cycle with breaking changes", + workflow: []workflowStep{ + { + command: "major", + currentTags: []string{"v1.3.0"}, + expectedTag: "v2.0.0-prerelease01", + expectedError: false, + }, + { + command: "prerelease", + currentTags: []string{"v1.3.0", "v2.0.0-prerelease01"}, + expectedTag: "v2.0.0-prerelease02", + expectedError: false, + }, + { + command: "release", + currentTags: []string{"v1.3.0", "v2.0.0-prerelease01", "v2.0.0-prerelease02"}, + expectedTag: "v2.0.0", + expectedError: false, + }, + }, + }, + { + name: "initial repository setup", + description: "Starting from empty repository to first release", + workflow: []workflowStep{ + { + command: "minor", + currentTags: []string{}, + expectedTag: "v0.1.0-prerelease01", + expectedError: false, + }, + { + command: "release", + currentTags: []string{"v0.1.0-prerelease01"}, + expectedTag: "v0.1.0", + expectedError: false, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Logf("Testing workflow: %s", tt.description) + + for i, step := range tt.workflow { + t.Logf("Step %d: %s command", i+1, step.command) + + ctrl := gomock.NewController(t) + + mockGit := NewMockGit(ctrl) + mockFS := NewMockFS(ctrl) + mockInteraction := NewMockUserInteraction(ctrl) + + config := Config{ + RepoRoot: "/test", + ExcludedDirs: []string{}, + RequiredBranch: "master", + Verbose: false, + Command: step.command, + SkipConfirmations: true, + } + + manager := NewReleaseManager(config, mockGit, mockFS, mockInteraction) + + // Setup mocks + mockGit.EXPECT().GetTags(gomock.Any()).Return(step.currentTags, nil) + + if !step.expectedError { + mockGit.EXPECT().GetCurrentBranch(gomock.Any()).Return("master", nil) + mockFS.EXPECT().FindGoModFiles(gomock.Any(), "/test").Return([]string{"."}, nil) + mockGit.EXPECT().CreateTag(gomock.Any(), step.expectedTag).Return(nil) + mockGit.EXPECT().PushTag(gomock.Any(), step.expectedTag).Return(nil) + } + + // Execute command + var err error + switch step.command { + case "minor": + err = manager.RunMinor(context.Background()) + case "major": + err = manager.RunMajor(context.Background()) + case "prerelease": + err = manager.RunPrerelease(context.Background()) + case "release": + err = manager.RunRelease(context.Background()) + } + + if step.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + ctrl.Finish() + } + }) + } +} + +type workflowStep struct { + command string + currentTags []string + expectedTag string + expectedError bool +} + +func TestMultiModuleComplexScenarios(t *testing.T) { + tests := []struct { + name string + currentTags []string + modules []string + command string + expectedTags []string + excludedModules []string + }{ + { + name: "multi-module minor release", + currentTags: []string{ + "v1.2.3", + "service1/v1.2.3", + "service2/v1.2.3", + "cmd/tool/v1.0.0", // This should be excluded + }, + modules: []string{".", "service1", "service2", "cmd/tool"}, + command: "minor", + expectedTags: []string{ + "v1.3.0-prerelease01", + "service1/v1.3.0-prerelease01", + "service2/v1.3.0-prerelease01", + }, + excludedModules: []string{"cmd/tool"}, + }, + { + name: "multi-module with version conflicts", + currentTags: []string{ + "v1.2.3", + "service1/v1.3.0", // Already has target version + "service2/v1.2.3", + }, + modules: []string{".", "service1", "service2"}, + command: "manual", + expectedTags: []string{ + "v1.3.0", // Root module + "service2/v1.3.0", // Service2 missing + // service1/v1.3.0 should be skipped + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGit := NewMockGit(ctrl) + mockFS := NewMockFS(ctrl) + mockInteraction := NewMockUserInteraction(ctrl) + + config := Config{ + RepoRoot: "/test", + ExcludedDirs: tt.excludedModules, + RequiredBranch: "master", + Verbose: false, + Command: tt.command, + SkipConfirmations: true, + } + + if tt.command == "manual" { + config.ManualVersion = "v1.3.0" + } + + manager := NewReleaseManager(config, mockGit, mockFS, mockInteraction) + + // Setup mocks + mockGit.EXPECT().GetTags(gomock.Any()).Return(tt.currentTags, nil) + mockGit.EXPECT().GetCurrentBranch(gomock.Any()).Return("master", nil) + mockFS.EXPECT().FindGoModFiles(gomock.Any(), "/test").Return(tt.modules, nil) + + // Expect tag creation only for expected tags + for _, tag := range tt.expectedTags { + mockGit.EXPECT().CreateTag(gomock.Any(), tag).Return(nil) + mockGit.EXPECT().PushTag(gomock.Any(), tag).Return(nil) + } + + // Execute command + var err error + if tt.command == "manual" { + err = manager.RunRelease(context.Background()) + } else { + err = manager.RunMinor(context.Background()) + } + + require.NoError(t, err) + }) + } +} + +func TestErrorRecoveryScenarios(t *testing.T) { + tests := []struct { + name string + description string + setupMocks func(*MockGit, *MockFS, *MockUserInteraction) + command string + expectedErr string + shouldRecover bool + }{ + { + name: "partial tag creation failure", + description: "Some tags created successfully, others failed", + setupMocks: func(mockGit *MockGit, mockFS *MockFS, mockInteraction *MockUserInteraction) { + mockGit.EXPECT().GetTags(gomock.Any()).Return([]string{"v1.2.3"}, nil) + mockGit.EXPECT().GetCurrentBranch(gomock.Any()).Return("master", nil) + mockFS.EXPECT().FindGoModFiles(gomock.Any(), "/test").Return([]string{".", "service1"}, nil) + + // First tag succeeds, second fails + mockGit.EXPECT().CreateTag(gomock.Any(), "v1.3.0-prerelease01").Return(nil) + mockGit.EXPECT().CreateTag(gomock.Any(), "service1/v1.3.0-prerelease01"). + Return(assert.AnError) + }, + command: "minor", + expectedErr: "failed to create tag", + }, + { + name: "network failure during push", + description: "Tags created locally but push failed", + setupMocks: func(mockGit *MockGit, mockFS *MockFS, mockInteraction *MockUserInteraction) { + mockGit.EXPECT().GetTags(gomock.Any()).Return([]string{"v1.2.3"}, nil) + mockGit.EXPECT().GetCurrentBranch(gomock.Any()).Return("master", nil) + mockFS.EXPECT().FindGoModFiles(gomock.Any(), "/test").Return([]string{"."}, nil) + + // Tag creation succeeds + mockGit.EXPECT().CreateTag(gomock.Any(), "v1.3.0-prerelease01").Return(nil) + // Push fails + mockGit.EXPECT().PushTag(gomock.Any(), "v1.3.0-prerelease01"). + Return(assert.AnError) + }, + command: "minor", + expectedErr: "push tags", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Logf("Testing scenario: %s", tt.description) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGit := NewMockGit(ctrl) + mockFS := NewMockFS(ctrl) + mockInteraction := NewMockUserInteraction(ctrl) + + config := Config{ + RepoRoot: "/test", + ExcludedDirs: []string{}, + RequiredBranch: "master", + Verbose: false, + Command: tt.command, + SkipConfirmations: true, + } + + manager := NewReleaseManager(config, mockGit, mockFS, mockInteraction) + + tt.setupMocks(mockGit, mockFS, mockInteraction) + + var err error + switch tt.command { + case "minor": + err = manager.RunMinor(context.Background()) + case "release": + err = manager.RunRelease(context.Background()) + } + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + }) + } +} + +func TestConcurrentPrereleaseScenarios(t *testing.T) { + tests := []struct { + name string + existingTags []string + expectedVersion string + description string + }{ + { + name: "gap in prerelease sequence", + existingTags: []string{ + "v1.3.0-prerelease01", + "v1.3.0-prerelease03", // Missing 02 + "v1.3.0-prerelease05", + }, + expectedVersion: "v1.3.0-prerelease06", // Should continue from highest + description: "Should continue from highest prerelease number even with gaps", + }, + { + name: "mixed prerelease versions", + existingTags: []string{ + "v1.2.0-prerelease05", + "v1.3.0-prerelease01", + "v1.3.0-prerelease02", + "v1.4.0-prerelease01", + }, + expectedVersion: "v1.4.0-prerelease02", // Should work with current global version + description: "Should handle mixed prerelease versions for different base versions", + }, + { + name: "approaching prerelease limit", + existingTags: []string{ + "v1.3.0-prerelease98", + }, + expectedVersion: "v1.3.0-prerelease99", + description: "Should handle high prerelease numbers approaching limit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Logf("Testing scenario: %s", tt.description) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGit := NewMockGit(ctrl) + mockFS := NewMockFS(ctrl) + mockInteraction := NewMockUserInteraction(ctrl) + + config := Config{ + RepoRoot: "/test", + ExcludedDirs: []string{}, + RequiredBranch: "master", + Verbose: false, + Command: "prerelease", + SkipConfirmations: true, + } + + manager := NewReleaseManager(config, mockGit, mockFS, mockInteraction) + + // Setup mocks + mockGit.EXPECT().GetTags(gomock.Any()).Return(tt.existingTags, nil) + mockGit.EXPECT().GetCurrentBranch(gomock.Any()).Return("master", nil) + mockFS.EXPECT().FindGoModFiles(gomock.Any(), "/test").Return([]string{"."}, nil) + mockGit.EXPECT().CreateTag(gomock.Any(), tt.expectedVersion).Return(nil) + mockGit.EXPECT().PushTag(gomock.Any(), tt.expectedVersion).Return(nil) + + err := manager.RunPrerelease(context.Background()) + require.NoError(t, err) + }) + } +} + +func TestUserInteractionScenarios(t *testing.T) { + tests := []struct { + name string + currentBranch string + userResponses []bool // Responses to confirmation prompts + skipConfirmations bool + shouldComplete bool + expectedActions int // Number of git operations expected + }{ + { + name: "user confirms all warnings and actions", + currentBranch: "feature-branch", // Wrong branch + userResponses: []bool{true, true, true}, // Continue despite warning, create tags, push tags + skipConfirmations: false, + shouldComplete: true, + expectedActions: 2, // create + push + }, + { + name: "user rejects branch warning", + currentBranch: "feature-branch", + userResponses: []bool{false}, // Reject continuing despite warning + skipConfirmations: false, + shouldComplete: false, + expectedActions: 0, + }, + { + name: "user confirms warning but rejects tag creation", + currentBranch: "feature-branch", + userResponses: []bool{true, false}, // Continue despite warning, but reject tag creation + skipConfirmations: false, + shouldComplete: false, + expectedActions: 0, + }, + { + name: "user creates tags but skips push", + currentBranch: "feature-branch", + userResponses: []bool{true, true, false}, // Continue, create, but don't push + skipConfirmations: false, + shouldComplete: false, // Partial completion + expectedActions: 1, // Only create + }, + { + name: "skip all confirmations", + currentBranch: "feature-branch", + userResponses: []bool{}, // No interactions expected + skipConfirmations: true, + shouldComplete: true, + expectedActions: 2, // create + push + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockGit := NewMockGit(ctrl) + mockFS := NewMockFS(ctrl) + mockInteraction := NewMockUserInteraction(ctrl) + + config := Config{ + RepoRoot: "/test", + ExcludedDirs: []string{}, + RequiredBranch: "master", + Verbose: false, + Command: "minor", + SkipConfirmations: tt.skipConfirmations, + } + + manager := NewReleaseManager(config, mockGit, mockFS, mockInteraction) + + // Setup basic mocks + mockGit.EXPECT().GetTags(gomock.Any()).Return([]string{"v1.2.3"}, nil) + mockGit.EXPECT().GetCurrentBranch(gomock.Any()).Return(tt.currentBranch, nil) + mockFS.EXPECT().FindGoModFiles(gomock.Any(), "/test").Return([]string{"."}, nil) + + // Setup user interaction mocks + responseIndex := 0 + if !tt.skipConfirmations { + if tt.currentBranch != "master" { + // Branch warning confirmation + if responseIndex < len(tt.userResponses) { + mockInteraction.EXPECT(). + ConfirmWithDefault(gomock.Any(), "Continue?", false). + Return(tt.userResponses[responseIndex], nil) + responseIndex++ + + if !tt.userResponses[responseIndex-1] { + // User rejected, no more interactions + goto executeTest + } + } + } + + // Tag creation confirmation + if responseIndex < len(tt.userResponses) { + mockInteraction.EXPECT(). + Confirm(gomock.Any(), gomock.Any()). + Return(tt.userResponses[responseIndex], nil) + responseIndex++ + + if !tt.userResponses[responseIndex-1] { + // User rejected tag creation + goto executeTest + } + } + + // Tag push confirmation + if responseIndex < len(tt.userResponses) { + mockInteraction.EXPECT(). + Confirm(gomock.Any(), gomock.Any()). + Return(tt.userResponses[responseIndex], nil) + } + } + + // Setup git operation mocks based on expected actions + if tt.expectedActions >= 1 { + mockGit.EXPECT().CreateTag(gomock.Any(), "v1.3.0-prerelease01").Return(nil) + } + if tt.expectedActions >= 2 { + mockGit.EXPECT().PushTag(gomock.Any(), "v1.3.0-prerelease01").Return(nil) + } + + executeTest: + err := manager.RunMinor(context.Background()) + + if tt.shouldComplete { + require.NoError(t, err) + } else { + // May have error or be cancelled + if err != nil { + t.Logf("Expected cancellation/error: %v", err) + } + } + }) + } +} diff --git a/cmd/tools/releaser/internal/release/types.go b/cmd/tools/releaser/internal/release/types.go new file mode 100644 index 00000000000..9137da8482d --- /dev/null +++ b/cmd/tools/releaser/internal/release/types.go @@ -0,0 +1,71 @@ +package release + +import "github.com/Masterminds/semver/v3" + +// State holds the current state of the repository +type State struct { + CurrentBranch string + Modules []Module + CurrentVersion string + TagCache *TagCache +} + +// Module represents a Go module +type Module struct { + Path string + Version string +} + +// ParsedTag represents a single parsed git tag +type ParsedTag struct { + Raw string // Original tag name (e.g., "service1/v1.2.3-prerelease01") + ModulePath string // Module path (e.g., "service1", "" for root) + Version *semver.Version // Parsed semantic version + GitSHA string // Git commit SHA + IsPrerelease bool + PrereleaseNum int // Extracted prerelease number (01, 02, etc.) +} + +// TagCache holds all parsed tag information +type TagCache struct { + AllTags []ParsedTag + VersionTags []ParsedTag // Only valid semver tags + ModuleTags map[string][]ParsedTag // Tags grouped by module path + PrereleaseCache map[string][]int // Base version -> prerelease numbers + HighestVersion *semver.Version // Cached highest version +} + +// VersionConflictInfo holds information about version conflicts +type VersionConflictInfo struct { + ExistingTags []string // Tags that already exist + MissingTags []string // Tags that need to be created +} + +// WarningType represents different types of warnings +type WarningType int + +const ( + WrongBranch WarningType = iota + ExistingTags +) + +// Warning represents a validation warning that can be overridden +type Warning struct { + Type WarningType + Message string +} + +// Action represents a planned release action +type Action struct { + Type ActionType + Target string // tag name, module path + Description string + GitSHA string // for existing tags +} + +type ActionType int + +const ( + ActionCreateTag ActionType = iota + ActionPushTags +) diff --git a/cmd/tools/releaser/internal/release/validate_test.go b/cmd/tools/releaser/internal/release/validate_test.go new file mode 100644 index 00000000000..543102bb92d --- /dev/null +++ b/cmd/tools/releaser/internal/release/validate_test.go @@ -0,0 +1,640 @@ +package release + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestValidateReleaseCommand(t *testing.T) { + tests := []struct { + name string + currentVersion string + shouldError bool + errorMsg string + }{ + { + name: "valid prerelease version", + currentVersion: "v1.2.3-prerelease01", + shouldError: false, + }, + { + name: "valid higher prerelease", + currentVersion: "v2.5.0-prerelease15", + shouldError: false, + }, + { + name: "invalid stable version", + currentVersion: "v1.2.3", + shouldError: true, + errorMsg: "release command requires existing prerelease version", + }, + { + name: "invalid initial version", + currentVersion: "v0.0.0", + shouldError: true, + errorMsg: "release command requires existing prerelease version", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{} + err := manager.validateReleaseCommand(tt.currentVersion) + + if tt.shouldError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateMinorMajorCommand(t *testing.T) { + tests := []struct { + name string + currentVersion string + shouldError bool + errorMsg string + }{ + { + name: "valid stable version", + currentVersion: "v1.2.3", + shouldError: false, + }, + { + name: "valid initial version", + currentVersion: "v0.0.0", + shouldError: false, + }, + { + name: "invalid prerelease version", + currentVersion: "v1.2.3-prerelease01", + shouldError: true, + errorMsg: "minor/major commands should be run from stable versions", + }, + { + name: "invalid higher prerelease", + currentVersion: "v2.5.0-prerelease15", + shouldError: true, + errorMsg: "minor/major commands should be run from stable versions", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{} + err := manager.validateMinorMajorCommand(tt.currentVersion) + + if tt.shouldError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestProcessManualVersion(t *testing.T) { + tests := []struct { + name string + manualVersion string + expected string + shouldError bool + errorMsg string + }{ + { + name: "valid version with v", + manualVersion: "v1.2.3", + expected: "v1.2.3", + shouldError: false, + }, + { + name: "valid version without v", + manualVersion: "1.2.3", + expected: "v1.2.3", + shouldError: false, + }, + { + name: "valid prerelease with v", + manualVersion: "v2.0.0-prerelease01", + expected: "v2.0.0-prerelease01", + shouldError: false, + }, + { + name: "valid prerelease without v", + manualVersion: "2.0.0-prerelease01", + expected: "v2.0.0-prerelease01", + shouldError: false, + }, + { + name: "auto-complete version format", + manualVersion: "1.2", + expected: "v1.2.0", + shouldError: false, + }, + { + name: "completely invalid", + manualVersion: "not-a-version", + shouldError: true, + errorMsg: "invalid manual version format", + }, + { + name: "empty version", + manualVersion: "", + shouldError: true, + errorMsg: "invalid manual version format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{} + result, err := manager.processManualVersion(tt.manualVersion) + + if tt.shouldError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestShouldExcludeModule(t *testing.T) { + tests := []struct { + name string + excludedDirs []string + modulePath string + shouldExclude bool + }{ + { + name: "not excluded", + excludedDirs: []string{"cmd", "internal/tools"}, + modulePath: "service1", + shouldExclude: false, + }, + { + name: "exact match", + excludedDirs: []string{"cmd", "internal/tools"}, + modulePath: "cmd", + shouldExclude: true, + }, + { + name: "prefix match", + excludedDirs: []string{"cmd", "internal/tools"}, + modulePath: "cmd/tool1", + shouldExclude: true, + }, + { + name: "nested prefix match", + excludedDirs: []string{"cmd", "internal/tools"}, + modulePath: "internal/tools/helper", + shouldExclude: true, + }, + { + name: "partial match should not exclude", + excludedDirs: []string{"cmd", "internal/tools"}, + modulePath: "cmdline", + shouldExclude: false, + }, + { + name: "empty excluded dirs", + excludedDirs: []string{}, + modulePath: "any/path", + shouldExclude: false, + }, + { + name: "root module", + excludedDirs: []string{"cmd"}, + modulePath: "", + shouldExclude: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{ + config: Config{ + ExcludedDirs: tt.excludedDirs, + }, + } + + result := manager.shouldExcludeModule(tt.modulePath) + assert.Equal(t, tt.shouldExclude, result) + }) + } +} + +func TestGetTagName(t *testing.T) { + tests := []struct { + name string + module Module + version string + expected string + }{ + { + name: "root module", + module: Module{Path: "", Version: "v1.2.3"}, + version: "v1.3.0", + expected: "v1.3.0", + }, + { + name: "submodule", + module: Module{Path: "service1", Version: "v1.2.3"}, + version: "v1.3.0", + expected: "service1/v1.3.0", + }, + { + name: "nested module", + module: Module{Path: "services/auth", Version: "v1.2.3"}, + version: "v2.0.0", + expected: "services/auth/v2.0.0", + }, + { + name: "prerelease version", + module: Module{Path: "service1", Version: "v1.2.3"}, + version: "v1.3.0-prerelease01", + expected: "service1/v1.3.0-prerelease01", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{} + result := manager.getTagName(tt.module, tt.version) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetLatestVersionForModule(t *testing.T) { + tests := []struct { + name string + modulePath string + tags []string + expected string + }{ + { + name: "no tags for module", + modulePath: "service1", + tags: []string{}, + expected: "v0.0.0", + }, + { + name: "single tag", + modulePath: "service1", + tags: []string{"service1/v1.2.3"}, + expected: "v1.2.3", + }, + { + name: "multiple tags - choose latest", + modulePath: "service1", + tags: []string{"service1/v1.2.3", "service1/v1.1.0", "service1/v2.0.0"}, + expected: "v2.0.0", + }, + { + name: "mix of stable and prerelease", + modulePath: "service1", + tags: []string{"service1/v1.2.3", "service1/v1.3.0-prerelease01"}, + expected: "v1.3.0-prerelease01", + }, + { + name: "root module", + modulePath: "", + tags: []string{"v1.2.3", "v1.1.0", "v2.0.0"}, + expected: "v2.0.0", + }, + { + name: "no cache initialized", + modulePath: "service1", + tags: nil, // Will simulate uninitialized cache + expected: "v0.0.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{} + + if tt.tags != nil { + // Initialize cache + manager.tagCache = &TagCache{ + ModuleTags: make(map[string][]ParsedTag), + } + + // Parse and add tags + for _, tag := range tt.tags { + parsed := manager.parseTag(tag) + if parsed.Version != nil { + if manager.tagCache.ModuleTags[parsed.ModulePath] == nil { + manager.tagCache.ModuleTags[parsed.ModulePath] = make([]ParsedTag, 0) + } + manager.tagCache.ModuleTags[parsed.ModulePath] = append( + manager.tagCache.ModuleTags[parsed.ModulePath], parsed) + } + } + } + + result := manager.getLatestVersionForModule(tt.modulePath) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFilterActionsForMissingTags(t *testing.T) { + tests := []struct { + name string + actions []Action + missingTags []string + expected []Action + }{ + { + name: "filter some actions", + actions: []Action{ + {Type: ActionCreateTag, Target: "v1.3.0"}, + {Type: ActionPushTags, Target: "v1.3.0"}, + {Type: ActionCreateTag, Target: "service1/v1.3.0"}, + {Type: ActionPushTags, Target: "service1/v1.3.0"}, + {Type: ActionCreateTag, Target: "service2/v1.3.0"}, + {Type: ActionPushTags, Target: "service2/v1.3.0"}, + }, + missingTags: []string{"v1.3.0", "service2/v1.3.0"}, + expected: []Action{ + {Type: ActionCreateTag, Target: "v1.3.0"}, + {Type: ActionPushTags, Target: "v1.3.0"}, + {Type: ActionCreateTag, Target: "service2/v1.3.0"}, + {Type: ActionPushTags, Target: "service2/v1.3.0"}, + }, + }, + { + name: "no missing tags", + actions: []Action{ + {Type: ActionCreateTag, Target: "v1.3.0"}, + {Type: ActionPushTags, Target: "v1.3.0"}, + }, + missingTags: []string{}, + expected: []Action(nil), + }, + { + name: "no actions", + actions: []Action{}, + missingTags: []string{"v1.3.0"}, + expected: []Action(nil), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{} + result := manager.filterActionsForMissingTags(tt.actions, tt.missingTags) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestValidateWithWarnings(t *testing.T) { + tests := []struct { + name string + currentBranch string + requiredBranch string + expectedWarnings int + expectedMessage string + }{ + { + name: "correct branch", + currentBranch: "master", + requiredBranch: "master", + expectedWarnings: 0, + }, + { + name: "wrong branch", + currentBranch: "feature-branch", + requiredBranch: "master", + expectedWarnings: 1, + expectedMessage: "you are not on master", + }, + { + name: "no required branch", + currentBranch: "any-branch", + requiredBranch: "", + expectedWarnings: 0, + }, + { + name: "different wrong branch", + currentBranch: "develop", + requiredBranch: "main", + expectedWarnings: 1, + expectedMessage: "you are not on main", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{ + config: Config{ + RequiredBranch: tt.requiredBranch, + }, + } + + state := &State{ + CurrentBranch: tt.currentBranch, + } + + warnings := manager.validateWithWarnings(state, "v1.3.0") + + assert.Len(t, warnings, tt.expectedWarnings) + if tt.expectedWarnings > 0 { + assert.Contains(t, warnings[0].Message, tt.expectedMessage) + assert.Equal(t, WrongBranch, warnings[0].Type) + } + }) + } +} + +func TestFindModules(t *testing.T) { + tests := []struct { + name string + goModPaths []string + excludedDirs []string + expected []Module + }{ + { + name: "single root module", + goModPaths: []string{"."}, + excludedDirs: []string{}, + expected: []Module{ + {Path: "", Version: "v0.0.0"}, + }, + }, + { + name: "multiple modules", + goModPaths: []string{".", "service1", "service2"}, + excludedDirs: []string{}, + expected: []Module{ + {Path: "", Version: "v0.0.0"}, + {Path: "service1", Version: "v0.0.0"}, + {Path: "service2", Version: "v0.0.0"}, + }, + }, + { + name: "modules with exclusions", + goModPaths: []string{".", "service1", "cmd/tool", "internal/tools/helper"}, + excludedDirs: []string{"cmd", "internal/tools"}, + expected: []Module{ + {Path: "", Version: "v0.0.0"}, + {Path: "service1", Version: "v0.0.0"}, + }, + }, + { + name: "deduplicate paths", + goModPaths: []string{".", "./", "service1", "./service1"}, + excludedDirs: []string{}, + expected: []Module{ + {Path: "", Version: "v0.0.0"}, + {Path: "service1", Version: "v0.0.0"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockFS := NewMockFS(ctrl) + manager := &Manager{ + config: Config{ + RepoRoot: "/test", + ExcludedDirs: tt.excludedDirs, + }, + fs: mockFS, + tagCache: &TagCache{ + ModuleTags: make(map[string][]ParsedTag), + }, + } + + mockFS.EXPECT().FindGoModFiles(gomock.Any(), "/test").Return(tt.goModPaths, nil) + + result, err := manager.FindModules(context.Background()) + require.NoError(t, err) + + assert.ElementsMatch(t, tt.expected, result) + }) + } +} + +func TestHandleWarningsAndConfirmations(t *testing.T) { + tests := []struct { + name string + warnings []Warning + skipConfirmations bool + userResponses []bool + shouldError bool + errorMsg string + }{ + { + name: "no warnings", + warnings: []Warning{}, + skipConfirmations: false, + shouldError: false, + }, + { + name: "skip confirmations with warnings", + warnings: []Warning{ + {Type: WrongBranch, Message: "you are not on master"}, + }, + skipConfirmations: true, + shouldError: false, + }, + { + name: "user confirms warning", + warnings: []Warning{ + {Type: WrongBranch, Message: "you are not on master"}, + }, + skipConfirmations: false, + userResponses: []bool{true}, + shouldError: false, + }, + { + name: "user rejects warning", + warnings: []Warning{ + {Type: WrongBranch, Message: "you are not on master"}, + }, + skipConfirmations: false, + userResponses: []bool{false}, + shouldError: true, + errorMsg: "operation cancelled due to: you are not on master", + }, + { + name: "multiple warnings - all confirmed", + warnings: []Warning{ + {Type: WrongBranch, Message: "you are not on master"}, + {Type: ExistingTags, Message: "some tags exist"}, + }, + skipConfirmations: false, + userResponses: []bool{true, true}, + shouldError: false, + }, + { + name: "multiple warnings - second rejected", + warnings: []Warning{ + {Type: WrongBranch, Message: "you are not on master"}, + {Type: ExistingTags, Message: "some tags exist"}, + }, + skipConfirmations: false, + userResponses: []bool{true, false}, + shouldError: true, + errorMsg: "operation cancelled due to: some tags exist", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockInteraction := NewMockUserInteraction(ctrl) + manager := &Manager{ + config: Config{ + SkipConfirmations: tt.skipConfirmations, + }, + interaction: mockInteraction, + } + + // Setup mock expectations + if !tt.skipConfirmations && len(tt.warnings) > 0 { + for i, response := range tt.userResponses { + if i < len(tt.warnings) { + mockInteraction.EXPECT(). + ConfirmWithDefault(gomock.Any(), "Continue?", false). + Return(response, nil) + + if !response { + break // Stop after first rejection + } + } + } + } + + err := manager.handleWarningsAndConfirmations(context.Background(), tt.warnings) + + if tt.shouldError { + require.Error(t, err) + if tt.errorMsg != "" { + assert.Contains(t, err.Error(), tt.errorMsg) + } + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/tools/releaser/internal/release/version_test.go b/cmd/tools/releaser/internal/release/version_test.go new file mode 100644 index 00000000000..1c54827d073 --- /dev/null +++ b/cmd/tools/releaser/internal/release/version_test.go @@ -0,0 +1,527 @@ +package release + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTag(t *testing.T) { + tests := []struct { + name string + rawTag string + expectedModule string + expectedVer string + expectedPre bool + expectedPreNum int + shouldHaveVer bool + }{ + { + name: "root module stable version", + rawTag: "v1.2.3", + expectedModule: "", + expectedVer: "1.2.3", + expectedPre: false, + shouldHaveVer: true, + }, + { + name: "root module prerelease", + rawTag: "v1.2.3-prerelease01", + expectedModule: "", + expectedVer: "1.2.3-prerelease01", + expectedPre: true, + expectedPreNum: 1, + shouldHaveVer: true, + }, + { + name: "submodule stable version", + rawTag: "service1/v1.2.3", + expectedModule: "service1", + expectedVer: "1.2.3", + expectedPre: false, + shouldHaveVer: true, + }, + { + name: "submodule prerelease", + rawTag: "service1/v1.2.3-prerelease05", + expectedModule: "service1", + expectedVer: "1.2.3-prerelease05", + expectedPre: true, + expectedPreNum: 5, + shouldHaveVer: true, + }, + { + name: "nested module", + rawTag: "services/auth/v2.1.0", + expectedModule: "services/auth", + expectedVer: "2.1.0", + expectedPre: false, + shouldHaveVer: true, + }, + { + name: "high prerelease number", + rawTag: "v1.2.3-prerelease99", + expectedModule: "", + expectedVer: "1.2.3-prerelease99", + expectedPre: true, + expectedPreNum: 99, + shouldHaveVer: true, + }, + { + name: "invalid version tag", + rawTag: "invalid-tag", + shouldHaveVer: false, + }, + { + name: "not a version tag", + rawTag: "refs/heads/master", + shouldHaveVer: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{} + parsed := manager.parseTag(tt.rawTag) + + assert.Equal(t, tt.rawTag, parsed.Raw) + assert.Equal(t, tt.expectedModule, parsed.ModulePath) + assert.Equal(t, tt.expectedPre, parsed.IsPrerelease) + + if tt.shouldHaveVer { + require.NotNil(t, parsed.Version) + assert.Equal(t, tt.expectedVer, parsed.Version.String()) + if tt.expectedPre { + assert.Equal(t, tt.expectedPreNum, parsed.PrereleaseNum) + } + } else { + assert.Nil(t, parsed.Version) + } + }) + } +} + +func TestIncrementVersion(t *testing.T) { + tests := []struct { + name string + current string + versionType string + expected string + shouldError bool + }{ + { + name: "increment major", + current: "v1.2.3", + versionType: "major", + expected: "v2.0.0", + }, + { + name: "increment minor", + current: "v1.2.3", + versionType: "minor", + expected: "v1.3.0", + }, + { + name: "increment patch", + current: "v1.2.3", + versionType: "patch", + expected: "v1.2.4", + }, + { + name: "increment from zero", + current: "v0.0.0", + versionType: "minor", + expected: "v0.1.0", + }, + { + name: "increment major from high version", + current: "v15.27.99", + versionType: "major", + expected: "v16.0.0", + }, + { + name: "invalid version type", + current: "v1.2.3", + versionType: "invalid", + shouldError: true, + }, + { + name: "invalid current version", + current: "invalid", + versionType: "major", + shouldError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := IncrementVersion(tt.current, tt.versionType) + + if tt.shouldError { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestNormalizeVersion(t *testing.T) { + tests := []struct { + name string + input string + expected string + shouldError bool + }{ + { + name: "already normalized", + input: "v1.2.3", + expected: "v1.2.3", + }, + { + name: "add v prefix", + input: "1.2.3", + expected: "v1.2.3", + }, + { + name: "prerelease with v", + input: "v1.2.3-prerelease01", + expected: "v1.2.3-prerelease01", + }, + { + name: "prerelease without v", + input: "1.2.3-prerelease01", + expected: "v1.2.3-prerelease01", + }, + { + name: "invalid semver", + input: "1.2", + expected: "v1.2.0", + }, + { + name: "completely invalid", + input: "not-a-version", + shouldError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := NormalizeVersion(tt.input) + + if tt.shouldError { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestGetNextPrereleaseVersion(t *testing.T) { + tests := []struct { + name string + baseVersion string + existingPres []int + expected string + shouldError bool + errorMsg string + }{ + { + name: "first prerelease", + baseVersion: "v1.2.3", + existingPres: []int{}, + expected: "v1.2.3-prerelease01", + }, + { + name: "increment from existing", + baseVersion: "v1.2.3", + existingPres: []int{1, 2, 3}, + expected: "v1.2.3-prerelease04", + }, + { + name: "increment from high number", + baseVersion: "v1.2.3", + existingPres: []int{25}, + expected: "v1.2.3-prerelease26", + }, + { + name: "unordered existing prereleases", + baseVersion: "v1.2.3", + existingPres: []int{3, 1, 5, 2}, + expected: "v1.2.3-prerelease06", + }, + { + name: "max prerelease exceeded", + baseVersion: "v1.2.3", + existingPres: []int{99}, // Next would be 100 + shouldError: true, + errorMsg: "maximum prerelease number (99) exceeded", + }, + { + name: "invalid base version", + baseVersion: "invalid", + shouldError: true, + errorMsg: "failed to parse base version", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{ + tagCache: &TagCache{ + PrereleaseCache: make(map[string][]int), + }, + } + + // Setup prerelease cache + if len(tt.existingPres) > 0 { + // Parse base version to get the clean string + if baseVer, err := semver.NewVersion(tt.baseVersion); err == nil { + cleanBase := "v" + baseVer.String() + manager.tagCache.PrereleaseCache[cleanBase] = tt.existingPres + } + } + + result, err := manager.GetNextPrereleaseVersion(tt.baseVersion) + + if tt.shouldError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestVersionCalculationMethods(t *testing.T) { + tests := []struct { + name string + method string + currentVersion string + expected string + shouldError bool + errorMsg string + }{ + // Release version calculations + { + name: "release from prerelease", + method: "release", + currentVersion: "v1.2.3-prerelease01", + expected: "v1.2.3", + }, + { + name: "release from high prerelease", + currentVersion: "v2.5.10-prerelease15", + method: "release", + expected: "v2.5.10", + }, + + // Minor version calculations + { + name: "minor from stable", + method: "minor", + currentVersion: "v1.2.3", + expected: "v1.3.0-prerelease01", + }, + { + name: "minor from prerelease base", + method: "minor", + currentVersion: "v1.2.3-prerelease05", + expected: "v1.3.0-prerelease01", + }, + + // Major version calculations + { + name: "major from stable", + method: "major", + currentVersion: "v1.2.3", + expected: "v2.0.0-prerelease01", + }, + { + name: "major from prerelease base", + method: "major", + currentVersion: "v1.2.3-prerelease05", + expected: "v2.0.0-prerelease01", + }, + + // Prerelease calculations + { + name: "prerelease from stable", + method: "prerelease", + currentVersion: "v1.2.3", + expected: "v1.2.3-prerelease01", + }, + { + name: "prerelease increment", + method: "prerelease", + currentVersion: "v1.2.3-prerelease01", + expected: "v1.2.3-prerelease02", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{ + tagCache: &TagCache{ + PrereleaseCache: make(map[string][]int), + }, + } + + // Setup prerelease cache for increment scenarios + if tt.method == "prerelease" && tt.currentVersion == "v1.2.3-prerelease01" { + manager.tagCache.PrereleaseCache["v1.2.3"] = []int{1} + } + + var result string + var err error + + switch tt.method { + case "release": + result, err = manager.calculateReleaseVersion(tt.currentVersion) + case "minor": + result, err = manager.calculateMinorVersion(tt.currentVersion) + case "major": + result, err = manager.calculateMajorVersion(tt.currentVersion) + case "prerelease": + result, err = manager.calculatePrereleaseVersion(tt.currentVersion) + } + + if tt.shouldError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestGetBaseVersion(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "stable version", + input: "v1.2.3", + expected: "v1.2.3", + }, + { + name: "prerelease version", + input: "v1.2.3-prerelease01", + expected: "v1.2.3", + }, + { + name: "high prerelease version", + input: "v2.5.10-prerelease25", + expected: "v2.5.10", + }, + { + name: "invalid version", + input: "invalid", + expected: "invalid", // Should return input unchanged + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{} + result := manager.getBaseVersion(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetCurrentGlobalVersion(t *testing.T) { + tests := []struct { + name string + versionTags []string + expected string + }{ + { + name: "no versions", + versionTags: []string{}, + expected: "v0.0.0", + }, + { + name: "single version", + versionTags: []string{"v1.2.3"}, + expected: "v1.2.3", + }, + { + name: "multiple versions", + versionTags: []string{"v1.2.3", "v1.1.0", "v2.0.0", "v1.3.0"}, + expected: "v2.0.0", + }, + { + name: "prerelease versions", + versionTags: []string{"v1.2.3", "v1.3.0-prerelease01", "v1.2.4"}, + expected: "v1.3.0-prerelease01", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + manager := &Manager{ + tagCache: &TagCache{ + VersionTags: make([]ParsedTag, 0), + }, + } + + // Parse and add version tags + for _, tag := range tt.versionTags { + parsed := manager.parseTag(tag) + if parsed.Version != nil { + manager.tagCache.VersionTags = append(manager.tagCache.VersionTags, parsed) + } + } + + // Sort and set highest version + if len(manager.tagCache.VersionTags) > 0 { + manager.sortVersionTags() + manager.tagCache.HighestVersion = manager.tagCache.VersionTags[len(manager.tagCache.VersionTags)-1].Version + } + + result := manager.GetCurrentGlobalVersion() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestSortVersionTags(t *testing.T) { + manager := &Manager{ + tagCache: &TagCache{ + VersionTags: []ParsedTag{ + {Raw: "v2.0.0", Version: semver.MustParse("v2.0.0")}, + {Raw: "v1.2.3", Version: semver.MustParse("v1.2.3")}, + {Raw: "v1.3.0-prerelease01", Version: semver.MustParse("v1.3.0-prerelease01")}, + {Raw: "v1.1.0", Version: semver.MustParse("v1.1.0")}, + }, + }, + } + + manager.sortVersionTags() + + expected := []string{ + "v1.1.0", + "v1.2.3", + "v1.3.0-prerelease01", + "v2.0.0", + } + + actual := make([]string, len(manager.tagCache.VersionTags)) + for i, tag := range manager.tagCache.VersionTags { + actual[i] = tag.Raw + } + + assert.Equal(t, expected, actual) +} diff --git a/cmd/tools/releaser/releaser.go b/cmd/tools/releaser/releaser.go new file mode 100644 index 00000000000..c0e5b73b897 --- /dev/null +++ b/cmd/tools/releaser/releaser.go @@ -0,0 +1,268 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + + "github.com/urfave/cli/v2" + + "github.com/uber/cadence/cmd/tools/releaser/internal/console" + "github.com/uber/cadence/cmd/tools/releaser/internal/fs" + "github.com/uber/cadence/cmd/tools/releaser/internal/git" + "github.com/uber/cadence/cmd/tools/releaser/internal/release" +) + +func main() { + cliApp := &cli.App{ + Name: "releaser", + Usage: "Cadence workflow release management tool", + Version: "0.1.0", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "verbose", + Aliases: []string{"i"}, + Usage: "Enable verbose output", + }, + &cli.BoolFlag{ + Name: "yes", + Usage: "Skip confirmation prompts", + }, + &cli.StringFlag{ + Name: "set-version", + Aliases: []string{"s"}, + Usage: "Override automatic version calculation with specific version", + }, + }, + Commands: []*cli.Command{ + { + Name: "release", + Usage: "Promote latest prerelease to final release", + Action: releaseCommand, + Description: `Converts the current prerelease version to a final release. +Example: v1.2.3-prerelease01 → v1.2.3 + +This command requires that the current version is a prerelease.`, + }, + { + Name: "minor", + Usage: "Start new minor version development cycle", + Action: minorCommand, + Description: `Increments the minor version and creates the first prerelease. +Example: v1.2.3 → v1.3.0-prerelease01 + +Use this when starting development of new features.`, + }, + { + Name: "major", + Usage: "Start new major version development cycle", + Action: majorCommand, + Description: `Increments the major version and creates the first prerelease. +Example: v1.2.3 → v2.0.0-prerelease01 + +Use this when introducing breaking changes.`, + }, + { + Name: "patch", + Usage: "Start new patch version development cycle", + Action: patchCommand, + Description: `Increments the patch version and creates the first prerelease. +Example: v1.2.3 → v1.2.4-prerelease01 + +Use this when starting hotfix or patch development.`, + }, + { + Name: "prerelease", + Usage: "Increment prerelease number", + Action: prereleaseCommand, + Description: `Increments the prerelease number for iterative development. +Example: v1.2.3-prerelease01 → v1.2.3-prerelease02 + +Use this during active development within a version cycle.`, + }, + { + Name: "status", + Usage: "Show current repository release status", + Action: statusCommand, + Description: `Displays current branch, version, and module information. +Use this to understand the current state before making releases.`, + }, + }, + CustomAppHelpTemplate: `NAME: + {{.Name}} - {{.Usage}} + +USAGE: + {{.HelpName}} [global options] command [command options] + +VERSION: + {{.Version}} + +GLOBAL OPTIONS: + {{range .VisibleFlags}}{{.}} + {{end}} + +COMMANDS: +{{range .Commands}}{{if not .HideHelp}} {{join .Names ", "}}{{ "\t\t"}}{{.Usage}}{{ "\n" }}{{end}}{{end}} + +EXAMPLES: + # Check current status + {{.HelpName}} status + + # Development workflow + {{.HelpName}} minor # Start new minor version: v1.2.3 → v1.3.0-prerelease01 + {{.HelpName}} major # Start new major version: v1.2.3 → v2.0.0-prerelease01 + {{.HelpName}} patch # Start new patch version: v1.2.3 → v1.2.4-prerelease01 + {{.HelpName}} prerelease # Iterate: v1.3.0-prerelease01 → v1.3.0-prerelease02 + {{.HelpName}} release # Finalize: v1.3.0-prerelease03 → v1.3.0 + + # Major version workflow + {{.HelpName}} major # Start major version: v1.3.0 → v2.0.0-prerelease01 + {{.HelpName}} prerelease # Iterate: v2.0.0-prerelease01 → v2.0.0-prerelease02 + {{.HelpName}} release # Finalize: v2.0.0-prerelease02 → v2.0.0 + + # Manual version override + {{.HelpName}} release --set-version v1.4.0 # Override automatic calculation + +SAFETY FEATURES: + - Validates current version state before operations + - Requires clean git working directory + - Enforces releases only from master branch + - Prevents creating duplicate versions + - Builds and tests before creating tags + - Interactive confirmations for all operations + +Use --yes to skip confirmation prompts for automation. +`, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + if err := cliApp.RunContext(ctx, os.Args); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func createManager(c *cli.Context, command string) (*release.Manager, error) { + cfg := release.Config{ + ExcludedDirs: []string{"cmd", "internal/tools", "idls"}, + RequiredBranch: "master", + Verbose: c.Bool("verbose"), + Command: command, + SkipConfirmations: c.Bool("yes"), + ManualVersion: c.String("set-version"), + } + + gitClient := git.NewGitClient(cfg.Verbose) + repo := fs.NewFileSystemClient(cfg.Verbose) + interaction := console.NewManager(os.Stdout, os.Stdin) + + manager := release.NewReleaseManager(cfg, gitClient, repo, interaction) + + // Get repo root and update cfg + repoRoot, err := gitClient.GetRepoRoot(c.Context) + if err != nil { + return nil, fmt.Errorf("failed to get repository root: %w", err) + } + cfg.RepoRoot = repoRoot + + return manager, nil +} + +func releaseCommand(c *cli.Context) error { + manager, err := createManager(c, "release") + if err != nil { + return cli.Exit(err.Error(), 1) + } + + if err := manager.RunRelease(c.Context); err != nil { + if c.Context.Err() != nil { + return nil + } + return cli.Exit(err.Error(), 1) + } + + return nil +} + +func minorCommand(c *cli.Context) error { + manager, err := createManager(c, "minor") + if err != nil { + return cli.Exit(err.Error(), 1) + } + + if err := manager.RunMinor(c.Context); err != nil { + if c.Context.Err() != nil { + return nil + } + return cli.Exit(err.Error(), 1) + } + + return nil +} + +func majorCommand(c *cli.Context) error { + manager, err := createManager(c, "major") + if err != nil { + return cli.Exit(err.Error(), 1) + } + + if err := manager.RunMajor(c.Context); err != nil { + if c.Context.Err() != nil { + return nil + } + return cli.Exit(err.Error(), 1) + } + + return nil +} + +func patchCommand(c *cli.Context) error { + manager, err := createManager(c, "patch") + if err != nil { + return cli.Exit(err.Error(), 1) + } + + if err := manager.RunPatch(c.Context); err != nil { + if c.Context.Err() != nil { + return nil + } + return cli.Exit(err.Error(), 1) + } + + return nil +} + +func prereleaseCommand(c *cli.Context) error { + manager, err := createManager(c, "prerelease") + if err != nil { + return cli.Exit(err.Error(), 1) + } + + if err := manager.RunPrerelease(c.Context); err != nil { + if c.Context.Err() != nil { + return nil + } + return cli.Exit(err.Error(), 1) + } + + return nil +} + +func statusCommand(c *cli.Context) error { + manager, err := createManager(c, "status") + if err != nil { + return cli.Exit(err.Error(), 1) + } + + if err := manager.ShowCurrentState(c.Context); err != nil { + if c.Context.Err() != nil { + return nil + } + return cli.Exit(err.Error(), 1) + } + + return nil +} diff --git a/go.mod b/go.mod index 3540ba47b8c..0231a0f304f 100644 --- a/go.mod +++ b/go.mod @@ -70,6 +70,7 @@ require ( ) require ( + github.com/Masterminds/semver/v3 v3.3.1 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 github.com/google/gofuzz v1.0.0 github.com/mark3labs/mcp-go v0.18.0 @@ -150,7 +151,7 @@ require ( golang.org/x/crypto v0.32.0 // indirect golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e // indirect golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect - golang.org/x/mod v0.18.0 // indirect + golang.org/x/mod v0.18.0 golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 // indirect diff --git a/go.sum b/go.sum index e5a60bd18f0..744effcfe6c 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k= github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=