From 923fddb9365c0cab4c4b38b6aced904289982117 Mon Sep 17 00:00:00 2001 From: Thomas Vilte Date: Mon, 22 Dec 2025 20:18:43 -0300 Subject: [PATCH 1/4] feat(release): enhance automation and error handling --- cmd/main.go | 2 +- internal/commands/release/create.go | 9 +- internal/commands/release/generate.go | 6 + internal/commands/release/preview.go | 6 + internal/commands/release/publish.go | 9 +- .../suggests_commits/suggests_commits.go | 12 +- internal/errors/errors.go | 98 ++- internal/git/git_service.go | 223 ++++++- internal/services/release_service.go | 602 +++++++++++++++++- internal/ui/ui.go | 20 +- 10 files changed, 896 insertions(+), 91 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 59790dc..d5c2d19 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -158,7 +158,7 @@ func initializeApp() (*cli.Command, error) { return issueService, nil } commands := []*cli.Command{ - suggests_commits.NewSuggestCommandFactory(commitService, commitHandler).CreateCommand(translations, cfgApp), + suggests_commits.NewSuggestCommandFactory(commitService, commitHandler, gitService).CreateCommand(translations, cfgApp), issues.NewIssuesCommandFactory( issueProvider, templateService, diff --git a/internal/commands/release/create.go b/internal/commands/release/create.go index 4609d3d..d7e2e72 100644 --- a/internal/commands/release/create.go +++ b/internal/commands/release/create.go @@ -86,6 +86,13 @@ func createReleaseAction(releaseSvc releaseService, trans *i18n.Translations, re fmt.Println(trans.GetMessage("release.creating", 0, nil)) fmt.Println() + if err := releaseSvc.ValidateMainBranch(ctx); err != nil { + log.Error("branch validation failed", + "error", err, + ) + return fmt.Errorf("%s", trans.GetMessage("release.error_invalid_branch", 0, struct{ Error string }{err.Error()})) + } + release, err := releaseSvc.AnalyzeNextRelease(ctx) if err != nil { log.Error("failed to analyze next release", @@ -139,7 +146,7 @@ func createReleaseAction(releaseSvc releaseService, trans *i18n.Translations, re sVersion := ui.NewSmartSpinner(trans.GetMessage("release.app_version_update_started", 0, struct{ Version string }{release.Version})) sVersion.Start() - if err := releaseSvc.UpdateAppVersion(release.Version); err != nil { + if err := releaseSvc.UpdateAppVersion(ctx, release.Version); err != nil { sVersion.Error(trans.GetMessage("release.error_updating_app_version", 0, struct{ Error string }{err.Error()})) return fmt.Errorf("error updating app version: %w", err) } diff --git a/internal/commands/release/generate.go b/internal/commands/release/generate.go index 5c76247..8a1f43a 100644 --- a/internal/commands/release/generate.go +++ b/internal/commands/release/generate.go @@ -50,6 +50,12 @@ func generateReleaseAction(releaseSvc releaseService, trans *i18n.Translations) fmt.Println(trans.GetMessage("release.generating", 0, nil)) fmt.Println() + if err := releaseSvc.ValidateMainBranch(ctx); err != nil { + log.Error("branch validation failed", + "error", err) + return fmt.Errorf("%s", trans.GetMessage("release.error_invalid_branch", 0, struct{ Error string }{err.Error()})) + } + release, err := releaseSvc.AnalyzeNextRelease(ctx) if err != nil { log.Error("failed to analyze next release", diff --git a/internal/commands/release/preview.go b/internal/commands/release/preview.go index a08da8d..51b270d 100644 --- a/internal/commands/release/preview.go +++ b/internal/commands/release/preview.go @@ -38,6 +38,12 @@ func previewReleaseAction(releaseSvc releaseService, trans *i18n.Translations) c fmt.Println(trans.GetMessage("release.analyzing", 0, nil)) fmt.Println() + if err := releaseSvc.ValidateMainBranch(ctx); err != nil { + log.Error("branch validation failed", + "error", err) + return fmt.Errorf("%s", trans.GetMessage("release.error_invalid_branch", 0, struct{ Error string }{err.Error()})) + } + release, err := releaseSvc.AnalyzeNextRelease(ctx) if err != nil { log.Error("failed to analyze next release", diff --git a/internal/commands/release/publish.go b/internal/commands/release/publish.go index e57a1e5..58a6c60 100644 --- a/internal/commands/release/publish.go +++ b/internal/commands/release/publish.go @@ -59,7 +59,14 @@ func publishReleaseAction(releaseSvc releaseService, log.Info("executing release publish command", "version", version, "draft", draft, - "build_binaries", buildBinaries) + "build_binaries", buildBinaries, + ) + + if err := releaseSvc.ValidateMainBranch(ctx); err != nil { + log.Error("branch validation failed", + "error", err) + return fmt.Errorf("%s", trans.GetMessage("release.error_invalid_branch", 0, struct{ Error string }{err.Error()})) + } release, err := releaseSvc.AnalyzeNextRelease(ctx) if err != nil { diff --git a/internal/commands/suggests_commits/suggests_commits.go b/internal/commands/suggests_commits/suggests_commits.go index 749d3e1..c38af5b 100644 --- a/internal/commands/suggests_commits/suggests_commits.go +++ b/internal/commands/suggests_commits/suggests_commits.go @@ -8,7 +8,6 @@ import ( "github.com/thomas-vilte/matecommit/internal/commands/completion_helper" "github.com/thomas-vilte/matecommit/internal/config" - "github.com/thomas-vilte/matecommit/internal/git" "github.com/thomas-vilte/matecommit/internal/i18n" "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" @@ -26,15 +25,21 @@ type commitHandler interface { HandleSuggestions(ctx context.Context, suggestions []models.CommitSuggestion) error } +type gitService interface { + ValidateGitConfig(ctx context.Context) error +} + type SuggestCommandFactory struct { commitService commitService commitHandler commitHandler + gitService gitService } -func NewSuggestCommandFactory(commitSvc commitService, commitHdlr commitHandler) *SuggestCommandFactory { +func NewSuggestCommandFactory(commitSvc commitService, commitHdlr commitHandler, gitSvc gitService) *SuggestCommandFactory { return &SuggestCommandFactory{ commitService: commitSvc, commitHandler: commitHdlr, + gitService: gitSvc, } } @@ -122,8 +127,7 @@ func (f *SuggestCommandFactory) createAction(cfg *config.Config, t *i18n.Transla ui.PrintSectionBanner(t.GetMessage("ui.generating_suggestions_banner", 0, nil)) - gitSvc := git.NewGitService() - if err := gitSvc.ValidateGitConfig(ctx); err != nil { + if err := f.gitService.ValidateGitConfig(ctx); err != nil { ui.HandleAppError(err, t) return err } diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 3582795..743e8f0 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -23,16 +23,52 @@ type AppError struct { } func (e *AppError) Error() string { + var msg string if e.Err != nil { - return fmt.Sprintf("%s: %s (%v)", e.Type, e.Message, e.Err) + msg = fmt.Sprintf("%s: %s (%v)", e.Type, e.Message, e.Err) + } else { + msg = fmt.Sprintf("%s: %s", e.Type, e.Message) } - return fmt.Sprintf("%s: %s", e.Type, e.Message) + + // Include stderr context if available for better error messages + if e.Context != nil { + if stderr, ok := e.Context["stderr"].(string); ok && stderr != "" { + msg += fmt.Sprintf(" - %s", stderr) + } + } + + return msg } func (e *AppError) Unwrap() error { return e.Err } +// WithError creates a new AppError with an underlying error +func (e *AppError) WithError(err error) *AppError { + return &AppError{ + Type: e.Type, + Message: e.Message, + Context: e.Context, + Err: err, + } +} + +// WithContext creates a new AppError with additional context +func (e *AppError) WithContext(key string, value interface{}) *AppError { + ctx := make(map[string]interface{}) + for k, v := range e.Context { + ctx[k] = v + } + ctx[key] = value + return &AppError{ + Type: e.Type, + Message: e.Message, + Context: ctx, + Err: e.Err, + } +} + // NewAppError creates a new AppError func NewAppError(t ErrorType, msg string, err error) *AppError { return &AppError{ @@ -44,23 +80,30 @@ func NewAppError(t ErrorType, msg string, err error) *AppError { // Git errors var ( - ErrNoChanges = NewAppError(TypeGit, "no staged changes detected", nil) - ErrGetBranch = NewAppError(TypeGit, "failed to get current branch", nil) - ErrNoBranch = NewAppError(TypeGit, "no branch detected", nil) - ErrGetRepoRoot = NewAppError(TypeGit, "failed to get repository root", nil) - ErrGetRepoURL = NewAppError(TypeGit, "failed to get repository URL", nil) - ErrGetCommits = NewAppError(TypeGit, "failed to get commits", nil) - ErrAddFile = NewAppError(TypeGit, "failed to add file to staging", nil) - ErrExtractRepoInfo = NewAppError(TypeGit, "failed to extract repository info", nil) - ErrCreateTag = NewAppError(TypeGit, "failed to create tag", nil) - ErrPushTag = NewAppError(TypeGit, "failed to push tag", nil) - ErrCreateCommit = NewAppError(TypeGit, "failed to create commit", nil) - ErrGetDiff = NewAppError(TypeGit, "failed to get diff", nil) - ErrNoDiff = NewAppError(TypeGit, "no differences detected", nil) -) - -// Git configuration errors -var ( + ErrNoChanges = NewAppError(TypeGit, "no staged changes detected", nil) + ErrGetBranch = NewAppError(TypeGit, "failed to get current branch", nil) + ErrNoBranch = NewAppError(TypeGit, "no branch detected", nil) + ErrGetRepoRoot = NewAppError(TypeGit, "failed to get repository root", nil) + ErrGetRepoURL = NewAppError(TypeGit, "failed to get repository URL", nil) + ErrGetCommits = NewAppError(TypeGit, "failed to get commits", nil) + ErrGetCommitCount = NewAppError(TypeGit, "failed to get commit count", nil) + ErrGetRecentCommits = NewAppError(TypeGit, "failed to get recent commit messages", nil) + ErrAddFile = NewAppError(TypeGit, "failed to add file to staging", nil) + ErrExtractRepoInfo = NewAppError(TypeGit, "failed to extract repository info", nil) + ErrCreateTag = NewAppError(TypeGit, "failed to create tag", nil) + ErrPushTag = NewAppError(TypeGit, "failed to push tag", nil) + ErrPush = NewAppError(TypeGit, "failed to push to remote", nil) + ErrFetchTags = NewAppError(TypeGit, "failed to fetch tags from remote", nil) + ErrCreateCommit = NewAppError(TypeGit, "failed to create commit", nil) + ErrGetDiff = NewAppError(TypeGit, "failed to get diff", nil) + ErrNoDiff = NewAppError(TypeGit, "no differences detected", nil) + ErrInvalidBranch = NewAppError(TypeGit, "must be on main or master branch to create releases", nil) + ErrTagNotFound = NewAppError(TypeGit, "tag not found in repository history", nil) + ErrInvalidTagFormat = NewAppError(TypeGit, "tag does not match semver format (vX.Y.Z)", nil) + ErrValidateTag = NewAppError(TypeGit, "failed to validate tag existence", nil) + ErrGetChangedFiles = NewAppError(TypeGit, "failed to get changed files", nil) + ErrGetTagDate = NewAppError(TypeGit, "failed to get tag date", nil) + ErrGetGitUser = NewAppError(TypeGit, "failed to get git user configuration", nil) ErrGitUserNotConfigured = NewAppError(TypeGit, "git user.name not configured", nil) ErrGitEmailNotConfigured = NewAppError(TypeGit, "git user.email not configured", nil) ErrNotInGitRepo = NewAppError(TypeGit, "not in a git repository", nil) @@ -103,20 +146,15 @@ var ( ErrGeminiQuotaExceeded = NewAppError(TypeAI, "Gemini API quota exceeded", nil) ) -// Internal errors -var ( - ErrNetwork = NewAppError(TypeInternal, "network error occurred", nil) - ErrBuild = NewAppError(TypeInternal, "build operation failed", nil) -) - // Update errors var ( ErrUpdateFailed = NewAppError(TypeUpdate, "failed to update application", nil) ) var ( - ErrBuildNoVersion = NewAppError(TypeInternal, "build version not specified", nil) - ErrBuildNoCommit = NewAppError(TypeInternal, "build commit not specified", nil) - ErrBuildNoBuildDir = NewAppError(TypeInternal, "build directory not specified", nil) - ErrBuildNoDate = NewAppError(TypeInternal, "build date not specified", nil) - ErrBuildFailed = NewAppError(TypeInternal, "build operation failed", nil) + ErrBuildNoVersion = NewAppError(TypeInternal, "build version not specified", nil) + ErrBuildNoCommit = NewAppError(TypeInternal, "build commit not specified", nil) + ErrBuildNoBuildDir = NewAppError(TypeInternal, "build directory not specified", nil) + ErrBuildNoDate = NewAppError(TypeInternal, "build date not specified", nil) + ErrBuildFailed = NewAppError(TypeInternal, "build operation failed", nil) + ErrVersionFileNotFound = NewAppError(TypeInternal, "version file not found", nil) ) diff --git a/internal/git/git_service.go b/internal/git/git_service.go index d7415e9..d82a6dc 100644 --- a/internal/git/git_service.go +++ b/internal/git/git_service.go @@ -4,12 +4,15 @@ import ( "context" "fmt" "os/exec" + "regexp" + "sort" "strings" "github.com/thomas-vilte/matecommit/internal/errors" "github.com/thomas-vilte/matecommit/internal/logger" "github.com/thomas-vilte/matecommit/internal/models" "github.com/thomas-vilte/matecommit/internal/regex" + "golang.org/x/mod/semver" ) type GitService struct{} @@ -37,7 +40,7 @@ func (s *GitService) GetChangedFiles(ctx context.Context) ([]string, error) { if err != nil { log.Error("git status failed", "error", err) - return nil, err + return nil, errors.ErrGetChangedFiles.WithError(err) } changes := make([]string, 0) @@ -69,7 +72,7 @@ func (s *GitService) GetDiff(ctx context.Context) (string, error) { if err != nil { log.Error("git diff --cached failed", "error", err) - return "", err + return "", errors.ErrGetDiff.WithError(err).WithContext("diff_type", "staged") } unstagedCmd := exec.CommandContext(ctx, "git", "diff") @@ -77,7 +80,7 @@ func (s *GitService) GetDiff(ctx context.Context) (string, error) { if err != nil { log.Error("git diff failed", "error", err) - return "", err + return "", errors.ErrGetDiff.WithError(err).WithContext("diff_type", "unstaged") } combinedDiff := string(stagedOutput) + string(unstageOutput) @@ -97,6 +100,12 @@ func (s *GitService) GetDiff(ctx context.Context) (string, error) { } } } + + // If still no diff after checking untracked files + if combinedDiff == "" { + log.Warn("no differences detected in repository") + return "", errors.ErrNoDiff + } } log.Debug("git diff completed", @@ -141,8 +150,7 @@ func (s *GitService) CreateCommit(ctx context.Context, message string) error { return errors.ErrGitEmailNotConfigured } - fullErr := fmt.Sprintf("%v: %s", err, stderrStr) - return fmt.Errorf("%w: %s", errors.ErrCreateCommit, fullErr) + return errors.ErrCreateCommit.WithError(err).WithContext("stderr", stderrStr) } log.Info("git commit created successfully") @@ -153,7 +161,7 @@ func (s *GitService) CreateCommit(ctx context.Context, message string) error { func (s *GitService) AddFileToStaging(ctx context.Context, file string) error { repoRoot, err := s.getRepoRoot(ctx) if err != nil { - return fmt.Errorf("%w: %v", errors.ErrGetRepoRoot, err) + return err } cmd := exec.CommandContext(ctx, "git", "add", "-A", "--", file) @@ -162,8 +170,8 @@ func (s *GitService) AddFileToStaging(ctx context.Context, file string) error { cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - fullErr := fmt.Sprintf("%v: %s", err, strings.TrimSpace(stderr.String())) - return fmt.Errorf("%w [%s]: %s", errors.ErrAddFile, file, fullErr) + stderrStr := strings.TrimSpace(stderr.String()) + return errors.ErrAddFile.WithError(err).WithContext("file", file).WithContext("stderr", stderrStr) } return nil } @@ -178,7 +186,7 @@ func (s *GitService) GetCurrentBranch(ctx context.Context) (string, error) { if err != nil { log.Error("failed to get current branch", "error", err) - return "", fmt.Errorf("%w: %v", errors.ErrGetBranch, err) + return "", errors.ErrGetBranch.WithError(err) } branchName := strings.TrimSpace(string(output)) @@ -203,7 +211,7 @@ func (s *GitService) GetRepoInfo(ctx context.Context) (string, string, string, e if err != nil { log.Error("failed to get remote URL", "error", err) - return "", "", "", fmt.Errorf("%w: %v", errors.ErrGetRepoURL, err) + return "", "", "", errors.ErrGetRepoURL.WithError(err) } url := strings.TrimSpace(string(output)) @@ -224,28 +232,59 @@ func (s *GitService) GetRepoInfo(ctx context.Context) (string, string, string, e } func (s *GitService) GetLastTag(ctx context.Context) (string, error) { - cmd := exec.CommandContext(ctx, "git", "describe", "--tags", "--abbrev=0") + log := logger.FromContext(ctx) + + cmd := exec.CommandContext(ctx, "git", "ls-remote", "--tags", "origin", "refs/tags/*") output, err := cmd.Output() + if err == nil && len(output) > 0 { + tags := s.parseRemoteTags(string(output)) + if len(tags) > 0 { + lastTag := s.getLatestSemverTag(tags) + if lastTag != "" { + log.Debug("last tag from remote", "tag", lastTag) + return lastTag, nil + } + } + } + + cmd = exec.CommandContext(ctx, "git", "describe", "--tags", "--abbrev=0", "--match", "v*") + output, err = cmd.Output() if err != nil { - // no tags found return "", nil } - return strings.TrimSpace(string(output)), nil + + tag := strings.TrimSpace(string(output)) + + if tag != "" { + if err := s.ValidateTagExists(ctx, tag); err != nil { + log.Warn("tag not found in current branch", "tag", tag, "error", err) + return s.getLastTagInCurrentBranch(ctx) + } + } + return tag, nil } func (s *GitService) GetCommitsSinceTag(ctx context.Context, tag string) ([]models.Commit, error) { + log := logger.FromContext(ctx) + var args []string if tag == "" { // if no previous tag exists, get all commits - args = []string{"log", "--pretty=format:%H|%s|%b", "--no-merges"} + args = []string{"log", "--pretty=format:%H|%an|%ae|%ad|%s|%b", "--no-merges", "--date=iso"} } else { - args = []string{"log", tag + "..HEAD", "--pretty=format:%H|%s|%b", "--no-merges"} + if err := s.ValidateTagExists(ctx, tag); err != nil { + log.Warn("tag not found, trying to fetch from remote", "tag", tag) + _ = exec.CommandContext(ctx, "git", "fetch", "origin", "tag", tag).Run() + } + + args = []string{"log", tag + "..HEAD", "--pretty=format:%H|%an|%ae|%ad|%s|%b", "--no-merges", "--date=iso"} } cmd := exec.CommandContext(ctx, "git", args...) output, err := cmd.Output() if err != nil { - return nil, fmt.Errorf("%w: %v", errors.ErrGetCommits, err) + log.Warn("failed to get commits with tag range, trying alternative", "error", err) + return s.getCommitsAlternative(ctx, tag) } if len(output) == 0 { @@ -259,13 +298,17 @@ func (s *GitService) GetCommitsSinceTag(ctx context.Context, tag string) ([]mode if line == "" { continue } - parts := strings.SplitN(line, "|", 3) - if len(parts) >= 2 { + parts := strings.SplitN(line, "|", 6) + if len(parts) >= 5 { commit := models.Commit{ - Message: parts[1], + Hash: parts[0], + Author: parts[1], + Email: parts[2], + Date: parts[3], + Message: parts[4], } - if len(parts) == 3 { - commit.Message = parts[1] + "\n" + parts[2] + if len(parts) == 6 && parts[5] != "" { + commit.Message = parts[4] + "\n" + parts[5] } commits = append(commits, commit) } @@ -283,7 +326,7 @@ func (s *GitService) GetCommitsBetweenTags(ctx context.Context, fromTag, toTag s cmd := exec.CommandContext(ctx, "git", args...) output, err := cmd.Output() if err != nil { - return nil, fmt.Errorf("%w: %v", errors.ErrGetCommits, err) + return nil, errors.ErrGetCommits.WithError(err).WithContext("from_tag", fromTag).WithContext("to_tag", toTag) } if len(output) == 0 { @@ -315,7 +358,7 @@ func (s *GitService) GetRecentCommitMessages(ctx context.Context, count int) ([] cmd := exec.CommandContext(ctx, "git", "log", fmt.Sprintf("-%d", count), "--pretty=format:%s %b") output, err := cmd.Output() if err != nil { - return nil, err + return nil, errors.ErrGetRecentCommits.WithError(err).WithContext("count", count) } lines := strings.Split(strings.TrimSpace(string(output)), "\n") return lines, nil @@ -323,25 +366,34 @@ func (s *GitService) GetRecentCommitMessages(ctx context.Context, count int) ([] func (s *GitService) CreateTag(ctx context.Context, version, message string) error { cmd := exec.CommandContext(ctx, "git", "tag", "-a", version, "-m", message) - return cmd.Run() + if err := cmd.Run(); err != nil { + return errors.ErrCreateTag.WithError(err).WithContext("version", version) + } + return nil } func (s *GitService) PushTag(ctx context.Context, version string) error { cmd := exec.CommandContext(ctx, "git", "push", "origin", version) - return cmd.Run() + if err := cmd.Run(); err != nil { + return errors.ErrPushTag.WithError(err).WithContext("version", version) + } + return nil } // Push pushes commits to the remote repository func (s *GitService) Push(ctx context.Context) error { cmd := exec.CommandContext(ctx, "git", "push") - return cmd.Run() + if err := cmd.Run(); err != nil { + return errors.ErrPush.WithError(err) + } + return nil } func (s *GitService) GetCommitCount(ctx context.Context) (int, error) { cmd := exec.CommandContext(ctx, "git", "rev-list", "--count", "HEAD") output, err := cmd.Output() if err != nil { - return 0, err + return 0, errors.ErrGetCommitCount.WithError(err) } count := 0 _, _ = fmt.Sscanf(strings.TrimSpace(string(output)), "%d", &count) @@ -353,17 +405,31 @@ func (s *GitService) GetTagDate(ctx context.Context, tag string) (string, error) cmd := exec.CommandContext(ctx, "git", "log", "-1", "--format=%ai", tag) output, err := cmd.Output() if err != nil { - return "", fmt.Errorf("error getting tag date: %w", err) + return "", errors.ErrGetTagDate.WithError(err).WithContext("tag", tag) } dateStr := strings.TrimSpace(string(output)) if len(dateStr) >= 10 { - return dateStr[:10], nil // Return YYYY-MM-DD + return dateStr[:10], nil } return dateStr, nil } +func (s *GitService) FetchTags(ctx context.Context) error { + log := logger.FromContext(ctx) + log.Debug("fetching tags from remote") + + cmd := exec.CommandContext(ctx, "git", "fetch", "--tags", "origin") + if err := cmd.Run(); err != nil { + log.Warn("failed to fetch tags from remote", "error", err) + return errors.ErrFetchTags.WithError(err) + } + + log.Debug("tags fetched successfully") + return nil +} + // ValidateGitConfig checks if git user.name and user.email are configured func (s *GitService) ValidateGitConfig(ctx context.Context) error { cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-dir") @@ -390,7 +456,7 @@ func (s *GitService) GetGitUserName(ctx context.Context) (string, error) { cmd := exec.CommandContext(ctx, "git", "config", "user.name") output, err := cmd.Output() if err != nil { - return "", err + return "", errors.ErrGetGitUser.WithError(err).WithContext("config_key", "user.name") } return strings.TrimSpace(string(output)), nil } @@ -400,11 +466,19 @@ func (s *GitService) GetGitUserEmail(ctx context.Context) (string, error) { cmd := exec.CommandContext(ctx, "git", "config", "user.email") output, err := cmd.Output() if err != nil { - return "", err + return "", errors.ErrGetGitUser.WithError(err).WithContext("config_key", "user.email") } return strings.TrimSpace(string(output)), nil } +func (s *GitService) ValidateTagExists(ctx context.Context, tag string) error { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", tag+"^{commit}") + if err := cmd.Run(); err != nil { + return errors.ErrValidateTag.WithError(err).WithContext("tag", tag) + } + return nil +} + func parseRepoURL(url string) (string, string, string, error) { var matches []string if regex.SSHRepo.MatchString(url) { @@ -419,7 +493,7 @@ func parseRepoURL(url string) (string, string, string, error) { return matches[2], repoName, provider, nil } - return "", "", "", fmt.Errorf("%w [%s]", errors.ErrExtractRepoInfo, url) + return "", "", "", errors.ErrExtractRepoInfo.WithContext("url", url) } func detectProvider(host string) string { @@ -437,7 +511,90 @@ func (s *GitService) getRepoRoot(ctx context.Context) (string, error) { cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel") output, err := cmd.Output() if err != nil { - return "", fmt.Errorf("%w: %v", errors.ErrGetRepoRoot, err) + return "", errors.ErrGetRepoRoot.WithError(err) } return strings.TrimSpace(string(output)), nil } + +func (s *GitService) parseRemoteTags(output string) []string { + var tags []string + lines := strings.Split(output, "\n") + for _, line := range lines { + parts := strings.Fields(line) + if len(parts) >= 2 { + ref := parts[1] + if strings.HasPrefix(ref, "refs/tags/") { + tag := strings.TrimPrefix(ref, "refs/tags/") + if regex.SemVer.MatchString(tag) { + tags = append(tags, tag) + } + } + } + } + return tags +} + +func (s *GitService) getLatestSemverTag(tags []string) string { + if len(tags) == 0 { + return "" + } + + sort.Slice(tags, func(i, j int) bool { + return semver.Compare("v"+strings.TrimPrefix(tags[i], "v"), "v"+strings.TrimPrefix(tags[j], "v")) > 0 + }) + return tags[0] +} + +func (s *GitService) getLastTagInCurrentBranch(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, "git", "log", "--oneline", "--decorate", "--simplify-by-decoration", "--all", "--match", "v*", "-1") + output, err := cmd.Output() + if err != nil { + return "", errors.ErrGetCommits.WithError(err).WithContext("operation", "get_last_tag_in_branch") + } + + line := strings.TrimSpace(string(output)) + tagMatch := regexp.MustCompile(`\(tag:\s*(v[\d.]+)\)`).FindStringSubmatch(line) + if len(tagMatch) > 1 { + return tagMatch[1], nil + } + return "", nil +} + +func (s *GitService) getCommitsAlternative(ctx context.Context, tag string) ([]models.Commit, error) { + cmd := exec.CommandContext(ctx, "git", "merge-base", tag, "HEAD") + mergeBase, err := cmd.Output() + if err != nil { + return nil, errors.ErrTagNotFound.WithError(err).WithContext("tag", tag) + } + + baseHash := strings.TrimSpace(string(mergeBase)) + args := []string{"log", baseHash + "..HEAD", "--pretty=format:%H|%an|%ae|%ad|%s|%b", "--no-merges", "--date=iso"} + + cmd = exec.CommandContext(ctx, "git", args...) + output, err := cmd.Output() + if err != nil { + return nil, errors.ErrGetCommits.WithError(err).WithContext("tag", tag).WithContext("operation", "alternative") + } + var commits []models.Commit + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if line == "" { + continue + } + parts := strings.SplitN(line, "|", 6) + if len(parts) >= 5 { + commit := models.Commit{ + Hash: parts[0], + Author: parts[1], + Email: parts[2], + Date: parts[3], + Message: parts[4], + } + if len(parts) == 6 && parts[5] != "" { + commit.Message = parts[4] + "\n" + parts[5] + } + commits = append(commits, commit) + } + } + return commits, nil +} diff --git a/internal/services/release_service.go b/internal/services/release_service.go index 52d1597..67eaf33 100644 --- a/internal/services/release_service.go +++ b/internal/services/release_service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "path/filepath" "regexp" "strconv" "strings" @@ -15,6 +16,7 @@ import ( "github.com/thomas-vilte/matecommit/internal/models" "github.com/thomas-vilte/matecommit/internal/ports" "github.com/thomas-vilte/matecommit/internal/regex" + "golang.org/x/mod/semver" ) // releaseGitService defines only the methods needed by ReleaseService. @@ -30,6 +32,9 @@ type releaseGitService interface { CreateCommit(ctx context.Context, message string) error Push(ctx context.Context) error GetRepoInfo(ctx context.Context) (string, string, string, error) + GetCurrentBranch(ctx context.Context) (string, error) + FetchTags(ctx context.Context) error + ValidateTagExists(ctx context.Context, tag string) error } type ReleaseService struct { @@ -75,6 +80,14 @@ func NewReleaseService( } func (s *ReleaseService) AnalyzeNextRelease(ctx context.Context) (*models.Release, error) { + log := logger.FromContext(ctx) + + if s.config != nil && s.config.AutoFetchTags { + if err := s.git.FetchTags(ctx); err != nil { + log.Warn("failed to fetch tags, continuing with local tags", "error", err) + } + } + lastTag, err := s.git.GetLastTag(ctx) if err != nil { return nil, domainErrors.NewAppError(domainErrors.TypeGit, "error getting last tag", err) @@ -86,6 +99,12 @@ func (s *ReleaseService) AnalyzeNextRelease(ctx context.Context) (*models.Releas return nil, domainErrors.NewAppError(domainErrors.TypeGit, "no commits found in repository", nil) } lastTag = "v0.0.0" + log.Info("no previous tag found, using v0.0.0 as baseline") + } else { + if !regex.SemVer.MatchString(lastTag) { + log.Warn("last tag does not match semver format", "tag", lastTag) + return nil, fmt.Errorf("%w: tag '%s'", domainErrors.ErrInvalidTagFormat, lastTag) + } } commits, err := s.git.GetCommitsSinceTag(ctx, lastTag) @@ -97,6 +116,12 @@ func (s *ReleaseService) AnalyzeNextRelease(ctx context.Context) (*models.Releas return nil, domainErrors.ErrNoChanges } + validCommits := s.filterValidCommits(commits) + if len(validCommits) == 0 && len(commits) > 0 { + log.Warn("no conventional commits found, but commits exist", + "total_commits", len(commits)) + } + release := &models.Release{ PreviousVersion: lastTag, AllCommits: commits, @@ -108,6 +133,10 @@ func (s *ReleaseService) AnalyzeNextRelease(ctx context.Context) (*models.Releas release.Version = newVersion release.VersionBump = bump + if err := s.validateVersionIncrement(lastTag, newVersion); err != nil { + log.Warn("version increment validation failed", "error", err) + } + return release, nil } @@ -614,50 +643,583 @@ func (s *ReleaseService) PushChanges(ctx context.Context) error { return s.git.Push(ctx) } -func (s *ReleaseService) UpdateAppVersion(version string) error { - mainGoFile := "cmd/main.go" - versionPattern := `Version:\s*".*"` +func (s *ReleaseService) UpdateAppVersion(ctx context.Context, version string) error { + log := logger.FromContext(ctx) - if s.config != nil { - if s.config.VersionFile != "" { - mainGoFile = s.config.VersionFile - } - if s.config.VersionPattern != "" { - versionPattern = s.config.VersionPattern + versionFile, versionPattern, err := s.FindVersionFile(ctx) + if err != nil { + log.Warn("could not auto-detect version file, using defaults", "error", err) + versionFile = "cmd/main.go" + versionPattern = `Version:\s*".*"` + + if s.config != nil { + if s.config.VersionFile != "" { + versionFile = s.config.VersionFile + } + if s.config.VersionPattern != "" { + versionPattern = s.config.VersionPattern + } } } - content, err := os.ReadFile(mainGoFile) + log.Debug("updating version", + "file", versionFile, + "pattern", versionPattern, + "new_version", version) + + content, err := os.ReadFile(versionFile) if err != nil { - return domainErrors.NewAppError(domainErrors.TypeInternal, fmt.Sprintf("failed to read version file: %s", mainGoFile), err) + return domainErrors.NewAppError(domainErrors.TypeInternal, + fmt.Sprintf("failed to read version file: %s", versionFile), err) } re, err := regexp.Compile(versionPattern) if err != nil { - return domainErrors.NewAppError(domainErrors.TypeInternal, fmt.Sprintf("invalid version pattern: %s", versionPattern), err) + return domainErrors.NewAppError(domainErrors.TypeInternal, + fmt.Sprintf("invalid version pattern: %s", versionPattern), err) } currentContent := string(content) if !re.MatchString(currentContent) { - return domainErrors.NewAppError(domainErrors.TypeInternal, fmt.Sprintf("version pattern not found in %s", mainGoFile), nil) + return domainErrors.NewAppError(domainErrors.TypeInternal, + fmt.Sprintf("version pattern not found in %s with pattern %s", versionFile, versionPattern), nil) } match := re.FindString(currentContent) + if match == "" { + return domainErrors.NewAppError(domainErrors.TypeInternal, + "could not find version match", nil) + } - valMatch := regex.QuotedString.FindStringIndex(match) + cleanVersion := strings.TrimPrefix(version, "v") - if valMatch == nil { - return domainErrors.NewAppError(domainErrors.TypeInternal, "could not find quoted string in matching pattern", nil) + var newMatch string + ext := filepath.Ext(versionFile) + + switch ext { + case ".json": + versionRe := regexp.MustCompile(`"version"\s*:\s*"([^"]+)"`) + newMatch = versionRe.ReplaceAllString(match, fmt.Sprintf(`"version": "%s"`, cleanVersion)) + case ".toml": + versionRe := regexp.MustCompile(`version\s*=\s*"([^"]+)"`) + newMatch = versionRe.ReplaceAllString(match, fmt.Sprintf(`version = "%s"`, cleanVersion)) + case ".xml": + versionRe := regexp.MustCompile(`([^<]+)`) + newMatch = versionRe.ReplaceAllString(match, fmt.Sprintf(`%s`, cleanVersion)) + case ".py": + if strings.Contains(match, `"`) { + versionRe := regexp.MustCompile(`"([^"]+)"`) + newMatch = versionRe.ReplaceAllString(match, fmt.Sprintf(`"%s"`, cleanVersion)) + } else if strings.Contains(match, `'`) { + versionRe := regexp.MustCompile(`'([^']+)'`) + newMatch = versionRe.ReplaceAllString(match, fmt.Sprintf(`'%s'`, cleanVersion)) + } + case ".go", ".js", ".ts", ".rs", ".php", ".rb": + if strings.Contains(match, `"`) { + valMatch := regex.QuotedString.FindStringIndex(match) + if valMatch != nil { + newMatch = match[:valMatch[0]] + fmt.Sprintf(`"%s"`, cleanVersion) + match[valMatch[1]:] + } else { + versionRe := regexp.MustCompile(`"([^"]+)"`) + newMatch = versionRe.ReplaceAllString(match, fmt.Sprintf(`"%s"`, cleanVersion)) + } + } else if strings.Contains(match, `'`) { + versionRe := regexp.MustCompile(`'([^']+)'`) + newMatch = versionRe.ReplaceAllString(match, fmt.Sprintf(`'%s'`, cleanVersion)) + } else { + versionRe := regexp.MustCompile(`[\d.]+`) + newMatch = versionRe.ReplaceAllString(match, cleanVersion) + } + default: + if strings.Contains(match, `"`) { + versionRe := regexp.MustCompile(`"([^"]+)"`) + newMatch = versionRe.ReplaceAllString(match, fmt.Sprintf(`"%s"`, cleanVersion)) + } else { + versionRe := regexp.MustCompile(`[\d.]+`) + newMatch = versionRe.ReplaceAllString(match, cleanVersion) + } } - cleanVersion := strings.TrimPrefix(version, "v") - newMatch := match[:valMatch[0]] + fmt.Sprintf(`"%s"`, cleanVersion) + match[valMatch[1]:] + if newMatch == "" { + newMatch = match + } newContent := strings.Replace(currentContent, match, newMatch, 1) - if err := os.WriteFile(mainGoFile, []byte(newContent), 0644); err != nil { - return domainErrors.NewAppError(domainErrors.TypeInternal, fmt.Sprintf("failed to write version file: %s", mainGoFile), err) + if err := os.WriteFile(versionFile, []byte(newContent), 0644); err != nil { + return domainErrors.NewAppError(domainErrors.TypeInternal, + fmt.Sprintf("failed to write version file: %s", versionFile), err) } + log.Info("version updated successfully", + "file", versionFile, + "version", version) + return nil } + +var commonVersionPatterns = []struct { + name string + pattern string +}{ + {"const Version", `const\s+Version\s*=\s*"([^"]+)"`}, + {"var Version", `var\s+Version\s*=\s*"([^"]+)"`}, + {"const Version", `const\s+Version\s*=\s*([\d.]+)`}, + {"var Version", `var\s+Version\s*=\s*([\d.]+)`}, + {"Version:", `Version:\s*"([^"]+)"`}, + {"Version =", `Version\s*=\s*"([^"]+)"`}, + {"CurrentVersion", `CurrentVersion\s*=\s*"([^"]+)"`}, + {"AppVersion", `AppVersion\s*=\s*"([^"]+)"`}, + {"VERSION", `VERSION\s*=\s*"([^"]+)"`}, +} + +func (s *ReleaseService) FindVersionFile(ctx context.Context) (string, string, error) { + log := logger.FromContext(ctx) + + if s.config != nil && s.config.VersionFile != "" { + pattern := s.config.VersionPattern + if pattern == "" { + detectedPattern, err := s.detectPatternInFile(s.config.VersionFile) + if err == nil && detectedPattern != "" { + pattern = detectedPattern + } else { + pattern = `Version:\s*".*"` + } + } + log.Debug("using configured version file", + "file", s.config.VersionFile, + "pattern", pattern) + return s.config.VersionFile, pattern, nil + } + + projectType := s.detectProjectType() + log.Debug("detected project type", "type", projectType) + + if files, ok := versionFilesByLanguage[projectType]; ok { + for _, filePath := range files { + if strings.Contains(filePath, "*") { + matches, err := filepath.Glob(filePath) + if err == nil && len(matches) > 0 { + filePath = matches[0] + } else { + continue + } + } + + if _, err := os.Stat(filePath); err == nil { + pattern, err := s.detectPatternInFileForLanguage(filePath, projectType) + if err == nil && pattern != "" { + log.Debug("version file found automatically", + "file", filePath, + "pattern", pattern, + "language", projectType) + return filePath, pattern, nil + } + } + } + } + + foundFile, foundPattern, err := s.searchVersionFileRecursive(projectType) + if err == nil && foundFile != "" { + log.Debug("version file found recursively", + "file", foundFile, + "pattern", foundPattern) + return foundFile, foundPattern, nil + } + + return "", "", fmt.Errorf("could not find version file for project type: %s", projectType) +} + +func (s *ReleaseService) ValidateMainBranch(ctx context.Context) error { + log := logger.FromContext(ctx) + + branch, err := s.git.GetCurrentBranch(ctx) + if err != nil { + return domainErrors.NewAppError(domainErrors.TypeGit, "error getting current branch", err) + } + + if branch != "main" && branch != "master" { + return fmt.Errorf("%w: currently on '%s'", domainErrors.ErrInvalidBranch, branch) + } + + log.Debug("branch validation passed", + "branch", branch, + ) + return nil +} + +func (s *ReleaseService) filterValidCommits(commits []models.Commit) []models.Commit { + var valid []models.Commit + for _, commit := range commits { + if regex.ConventionalCommit.MatchString(commit.Message) { + valid = append(valid, commit) + } + } + return valid +} + +func (s *ReleaseService) validateVersionIncrement(oldVersion, newVersion string) error { + oldClean := strings.TrimPrefix(oldVersion, "v") + newClean := strings.TrimPrefix(newVersion, "v") + + if semver.Compare("v"+oldClean, "v"+newClean) >= 0 { + return fmt.Errorf("new version %s must be greater than previous version %s", newVersion, oldVersion) + } + return nil +} + +func (s *ReleaseService) detectPatternInFile(filePath string) (string, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + + fileContent := string(content) + + lang := detectLanguageFromFile(filePath) + + for _, patternInfo := range commonVersionPatterns { + re, err := regexp.Compile(patternInfo.pattern) + if err != nil { + continue + } + if re.MatchString(fileContent) { + adjustedPattern := s.adjustPatternForReplacement(patternInfo.pattern, fileContent, lang) + return adjustedPattern, nil + } + } + return "", fmt.Errorf("no version pattern found in file") +} + +func (s *ReleaseService) adjustPatternForReplacement(basePattern, content, lang string) string { + patterns := map[string][]string{ + "go": { + `const\s+Version\s*=\s*"[^"]*"`, + `var\s+Version\s*=\s*"[^"]*"`, + `Version:\s*"[^"]*"`, + }, + "python": { + `__version__\s*=\s*"[^"]*"`, + `__version__\s*=\s*'[^']*'`, + `version\s*=\s*"[^"]*"`, + }, + "js": { + `"version"\s*:\s*"[^"]*"`, + `'version'\s*:\s*'[^']*'`, + }, + "rust": { + `version\s*=\s*"[^"]*"`, + }, + "java": { + `[^<]+`, + }, + "csharp": { + `AssemblyVersion\s*\(\s*"[^"]*"`, + `[^<]+`, + }, + "php": { + `"version"\s*:\s*"[^"]*"`, + }, + "ruby": { + `VERSION\s*=\s*['"][^'"]*['"]`, + }, + } + + if langPatterns, ok := patterns[lang]; ok { + for _, pattern := range langPatterns { + re := regexp.MustCompile(pattern) + if re.MatchString(content) { + return pattern + } + } + } + + return basePattern +} + +func (s *ReleaseService) searchVersionFileRecursive(lang string) (string, string, error) { + searchDirs := map[string][]string{ + "go": {"internal", "pkg", "version", "cmd"}, + "python": {"src", "lib", "."}, + "js": {"src", "lib", "."}, + "rust": {"."}, + "java": {"src", "."}, + "csharp": {"Properties", "."}, + "php": {"src", "."}, + "ruby": {"lib", "."}, + } + + dirs, ok := searchDirs[lang] + if !ok { + dirs = []string{"."} + } + + extensions := map[string][]string{ + "go": {".go"}, + "python": {".py"}, + "js": {".js", ".ts"}, + "rust": {".rs", ".toml"}, + "java": {".xml", ".gradle", ".properties"}, + "csharp": {".cs", ".csproj", ".props"}, + "php": {".php", ".json"}, + "ruby": {".rb", ".gemspec"}, + } + + exts, ok := extensions[lang] + if !ok { + exts = []string{""} + } + + for _, dir := range dirs { + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + return nil + } + + hasValidExt := false + for _, ext := range exts { + if ext == "" || strings.HasSuffix(path, ext) { + hasValidExt = true + break + } + } + if !hasValidExt { + return nil + } + + if strings.Contains(strings.ToLower(path), "version") || + strings.Contains(strings.ToLower(info.Name()), "version") || + path == "package.json" || path == "Cargo.toml" || path == "setup.py" { + pattern, err := s.detectPatternInFileForLanguage(path, lang) + if err == nil && pattern != "" { + return fmt.Errorf("found: %s", path) + } + } + return nil + }) + + if err != nil && strings.HasPrefix(err.Error(), "found: ") { + foundPath := strings.TrimPrefix(err.Error(), "found: ") + pattern, _ := s.detectPatternInFileForLanguage(foundPath, lang) + return foundPath, pattern, nil + } + } + + return "", "", fmt.Errorf("version file not found") +} + +func (s *ReleaseService) detectProjectType() string { + indicators := map[string][]string{ + "go": {"go.mod", "Gopkg.toml", "glide.yaml"}, + "python": {"setup.py", "pyproject.toml", "requirements.txt", "Pipfile"}, + "js": {"package.json", "yarn.lock", "package-lock.json"}, + "rust": {"Cargo.toml"}, + "java": {"pom.xml", "build.gradle", "build.gradle.kts"}, + "csharp": {".csproj", ".sln", "project.json"}, + "php": {"composer.json"}, + "ruby": {"Gemfile", "Rakefile"}, + } + + for lang, files := range indicators { + for _, file := range files { + if _, err := os.Stat(file); err == nil { + return lang + } + } + } + + if hasFilesWithExtension(".go") { + return "go" + } + if hasFilesWithExtension(".py") { + return "python" + } + if hasFilesWithExtension(".js") || hasFilesWithExtension(".ts") { + return "js" + } + if hasFilesWithExtension(".rs") { + return "rust" + } + + return "unknown" +} + +func hasFilesWithExtension(ext string) bool { + entries, err := os.ReadDir(".") + if err != nil { + return false + } + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ext) { + return true + } + } + return false +} + +var versionFilesByLanguage = map[string][]string{ + "go": { + "internal/version/version.go", + "pkg/version/version.go", + "version/version.go", + "cmd/main.go", + "main.go", + "internal/version.go", + "pkg/version.go", + }, + "python": { + "__version__.py", + "version.py", + "src/*/__version__.py", + "setup.py", + "pyproject.toml", + }, + "js": { + "package.json", + "src/version.js", + "src/version.ts", + "lib/version.js", + }, + "rust": { + "Cargo.toml", + }, + "java": { + "pom.xml", + "build.gradle", + "build.gradle.kts", + "src/main/resources/version.properties", + }, + "csharp": { + "Properties/AssemblyInfo.cs", + "Directory.Build.props", + "*.csproj", + }, + "php": { + "composer.json", + "src/Version.php", + }, + "ruby": { + "lib/*/version.rb", + "version.rb", + "*.gemspec", + }, +} + +var versionPatternsByLanguage = map[string][]struct { + name string + pattern string +}{ + "go": { + {"const Version", `const\s+Version\s*=\s*"([^"]+)"`}, + {"var Version", `var\s+Version\s*=\s*"([^"]+)"`}, + {"const Version", `const\s+Version\s*=\s*([\d.]+)`}, + {"var Version", `var\s+Version\s*=\s*([\d.]+)`}, + {"Version:", `Version:\s*"([^"]+)"`}, + {"Version =", `Version\s*=\s*"([^"]+)"`}, + }, + "python": { + {"__version__", `__version__\s*=\s*"([^"]+)"`}, + {"__version__", `__version__\s*=\s*['"]([^'"]+)['"]`}, + {"version", `version\s*=\s*"([^"]+)"`}, + {"version", `version\s*=\s*['"]([^'"]+)['"]`}, + {"VERSION", `VERSION\s*=\s*"([^"]+)"`}, + }, + "js": { + {"version", `"version"\s*:\s*"([^"]+)"`}, + {"version", `'version'\s*:\s*'([^']+)'`}, + {"export const version", `export\s+const\s+version\s*=\s*"([^"]+)"`}, + {"export const version", `export\s+const\s+version\s*=\s*'([^']+)'`}, + }, + "rust": { + {"version", `version\s*=\s*"([^"]+)"`}, + }, + "java": { + {"version", `([^<]+)`}, + {"version", `version\s*=\s*['"]([^'"]+)['"]`}, + }, + "csharp": { + {"AssemblyVersion", `AssemblyVersion\s*\(\s*"([^"]+)"`}, + {"Version", `([^<]+)`}, + {"version", `"version"\s*:\s*"([^"]+)"`}, + }, + "php": { + {"version", `"version"\s*:\s*"([^"]+)"`}, + {"const VERSION", `const\s+VERSION\s*=\s*['"]([^'"]+)['"]`}, + }, + "ruby": { + {"VERSION", `VERSION\s*=\s*['"]([^'"]+)['"]`}, + {"version", `\.version\s*=\s*['"]([^'"]+)['"]`}, + }, +} + +func (s *ReleaseService) detectPatternInFileForLanguage(filePath, lang string) (string, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + + fileContent := string(content) + patterns, ok := versionPatternsByLanguage[lang] + if !ok { + return s.detectPatternInFile(filePath) + } + + for _, patternInfo := range patterns { + re, err := regexp.Compile(patternInfo.pattern) + if err != nil { + continue + } + if re.MatchString(fileContent) { + adjustedPattern := s.adjustPatternForReplacement(patternInfo.pattern, fileContent, lang) + return adjustedPattern, nil + } + } + + return "", fmt.Errorf("no version pattern found in file") +} + +func detectLanguageFromFile(filePath string) string { + ext := filepath.Ext(filePath) + extToLang := map[string]string{ + ".go": "go", + ".py": "python", + ".js": "js", + ".ts": "js", + ".rs": "rust", + ".toml": "rust", + ".xml": "java", + ".cs": "csharp", + ".csproj": "csharp", + ".props": "csharp", + ".php": "php", + ".rb": "ruby", + ".gemspec": "ruby", + ".json": "js", + } + + filename := strings.ToLower(filepath.Base(filePath)) + if filename == "package.json" || filename == "package-lock.json" { + return "js" + } + if filename == "composer.json" { + return "php" + } + if filename == "cargo.toml" { + return "rust" + } + if filename == "pom.xml" { + return "java" + } + if filename == "setup.py" || filename == "pyproject.toml" { + return "python" + } + + if lang, ok := extToLang[ext]; ok { + return lang + } + + return "unknown" +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 6edd957..3e5452b 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -26,7 +26,6 @@ var ( // Emojis with colors SuccessEmoji = Success.Sprint("✓") - ErrorEmoji = Error.Sprint("✗") WarningEmoji = Warning.Sprint("⚠") InfoEmoji = Info.Sprint("*") RocketEmoji = Accent.Sprint("🚀") @@ -234,6 +233,9 @@ func HandleAppError(err error, t *i18n.Translations) { if errors.Is(appErr, domainErrors.ErrNoChanges) { msg = t.GetMessage("ui_error.no_changes_detected", 0, nil) suggestion = t.GetMessage("ui_error.ensure_modified_files", 0, nil) + } else if errors.Is(appErr, domainErrors.ErrNoDiff) { + msg = t.GetMessage("ui_error.no_changes_detected", 0, nil) + suggestion = t.GetMessage("ui_error.ensure_modified_files", 0, nil) } else if errors.Is(appErr, domainErrors.ErrGitUserNotConfigured) { msg = t.GetMessage("ui_error.git_user_not_configured", 0, nil) suggestion = t.GetMessage("ui_error.git_config_user_suggestion", 0, nil) @@ -243,11 +245,27 @@ func HandleAppError(err error, t *i18n.Translations) { } else if errors.Is(appErr, domainErrors.ErrNotInGitRepo) { msg = t.GetMessage("ui_error.not_in_git_repo", 0, nil) suggestion = t.GetMessage("ui_error.git_init_suggestion", 0, nil) + } else if errors.Is(appErr, domainErrors.ErrInvalidBranch) { + msg = appErr.Message + suggestion = "Switch to main or master branch to create releases" + } else if errors.Is(appErr, domainErrors.ErrTagNotFound) { + msg = appErr.Message + if tagCtx, ok := appErr.Context["tag"].(string); ok { + suggestion = fmt.Sprintf("Tag '%s' not found in repository. Check available tags with: git tag -l", tagCtx) + } + } else if errors.Is(appErr, domainErrors.ErrInvalidTagFormat) { + msg = appErr.Message + suggestion = "Tags must follow semantic versioning format (vX.Y.Z), e.g., v1.0.0" } else { msg = appErr.Message if appErr.Err != nil { suggestion = appErr.Err.Error() } + if appErr.Context != nil { + if file, ok := appErr.Context["file"].(string); ok { + suggestion = fmt.Sprintf("File: %s - %s", file, suggestion) + } + } } case domainErrors.TypeAI: if errors.Is(appErr, domainErrors.ErrGeminiAPIKeyInvalid) { From f0d9afb2060dfd3c9f4689e47a9168cf20deef8c Mon Sep 17 00:00:00 2001 From: Thomas Vilte Date: Mon, 22 Dec 2025 20:19:19 -0300 Subject: [PATCH 2/4] feat(release): add branch validation and multi-language versioning --- internal/commands/release/release.go | 6 +- .../suggests_commits/suggests_commits_test.go | 29 +- internal/config/config.go | 3 +- internal/git/git_service_test.go | 390 ++++++++- internal/i18n/locales/active.en.toml | 1 + internal/models/pr.go | 4 + internal/services/release_service_test.go | 765 +++++++++++++++++- 7 files changed, 1157 insertions(+), 41 deletions(-) diff --git a/internal/commands/release/release.go b/internal/commands/release/release.go index a5da799..f955206 100644 --- a/internal/commands/release/release.go +++ b/internal/commands/release/release.go @@ -28,7 +28,8 @@ type releaseService interface { UpdateLocalChangelog(release *models.Release, notes *models.ReleaseNotes) error CommitChangelog(ctx context.Context, version string) error PushChanges(ctx context.Context) error - UpdateAppVersion(version string) error + UpdateAppVersion(ctx context.Context, version string) error + ValidateMainBranch(ctx context.Context) error } // gitService is a minimal interface for testing purposes @@ -48,6 +49,9 @@ type gitService interface { HasStagedChanges(ctx context.Context) bool CreateCommit(ctx context.Context, message string) error Push(ctx context.Context) error + FetchTags(ctx context.Context) error + ValidateGitConfig(ctx context.Context) error + ValidateTagExists(ctx context.Context, tag string) error } type ReleaseCommandFactory struct { diff --git a/internal/commands/suggests_commits/suggests_commits_test.go b/internal/commands/suggests_commits/suggests_commits_test.go index f49c28a..6ab8ff5 100644 --- a/internal/commands/suggests_commits/suggests_commits_test.go +++ b/internal/commands/suggests_commits/suggests_commits_test.go @@ -33,6 +33,16 @@ func (m *MockCommitHandler) HandleSuggestions(ctx context.Context, suggestions [ return args.Error(0) } +// Mock para GitService +type MockGitService struct { + mock.Mock +} + +func (m *MockGitService) ValidateGitConfig(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + func setupTestEnv(t *testing.T) (*config.Config, *i18n.Translations, func()) { tmpDir, err := os.MkdirTemp("", "matecommit-test-*") if err != nil { @@ -67,6 +77,7 @@ func TestSuggestCommand(t *testing.T) { mockService := new(MockCommitService) mockHandler := new(MockCommitHandler) + mockGit := new(MockGitService) ctx := context.Background() suggestions := []models.CommitSuggestion{ @@ -77,10 +88,11 @@ func TestSuggestCommand(t *testing.T) { }, } + mockGit.On("ValidateGitConfig", mock.Anything).Return(nil) mockService.On("GenerateSuggestions", mock.Anything, cfg.SuggestionsCount, 0, mock.Anything).Return(suggestions, nil) mockHandler.On("HandleSuggestions", mock.Anything, suggestions).Return(nil) - factory := NewSuggestCommandFactory(mockService, mockHandler) + factory := NewSuggestCommandFactory(mockService, mockHandler, mockGit) cmd := factory.CreateCommand(translations, cfg) // Act @@ -99,8 +111,9 @@ func TestSuggestCommand(t *testing.T) { mockService := new(MockCommitService) mockHandler := new(MockCommitHandler) + mockGit := new(MockGitService) - factory := NewSuggestCommandFactory(mockService, mockHandler) + factory := NewSuggestCommandFactory(mockService, mockHandler, mockGit) cmd := factory.CreateCommand(translations, cfg) ctx := context.Background() @@ -125,6 +138,7 @@ func TestSuggestCommand(t *testing.T) { mockService := new(MockCommitService) mockHandler := new(MockCommitHandler) + mockGit := new(MockGitService) suggestions := []models.CommitSuggestion{ { @@ -134,10 +148,11 @@ func TestSuggestCommand(t *testing.T) { }, } + mockGit.On("ValidateGitConfig", mock.Anything).Return(nil) mockService.On("GenerateSuggestions", mock.Anything, cfg.SuggestionsCount, 0, mock.Anything).Return(suggestions, nil) mockHandler.On("HandleSuggestions", mock.Anything, suggestions).Return(nil) - factory := NewSuggestCommandFactory(mockService, mockHandler) + factory := NewSuggestCommandFactory(mockService, mockHandler, mockGit) command := factory.CreateCommand(translations, cfg) ctx := context.Background() @@ -159,6 +174,7 @@ func TestSuggestCommand(t *testing.T) { mockService := new(MockCommitService) mockHandler := new(MockCommitHandler) + mockGit := new(MockGitService) suggestions := []models.CommitSuggestion{ { @@ -168,10 +184,11 @@ func TestSuggestCommand(t *testing.T) { }, } + mockGit.On("ValidateGitConfig", mock.Anything).Return(nil) mockService.On("GenerateSuggestions", mock.Anything, cfg.SuggestionsCount, 0, mock.Anything).Return(suggestions, nil) mockHandler.On("HandleSuggestions", mock.Anything, suggestions).Return(nil) - factory := NewSuggestCommandFactory(mockService, mockHandler) + factory := NewSuggestCommandFactory(mockService, mockHandler, mockGit) command := factory.CreateCommand(translations, cfg) ctx := context.Background() @@ -193,11 +210,13 @@ func TestSuggestCommand(t *testing.T) { mockService := new(MockCommitService) mockHandler := new(MockCommitHandler) + mockGit := new(MockGitService) expectedError := fmt.Errorf("service error") + mockGit.On("ValidateGitConfig", mock.Anything).Return(nil) mockService.On("GenerateSuggestions", mock.Anything, cfg.SuggestionsCount, 0, mock.Anything).Return([]models.CommitSuggestion{}, expectedError) - factory := NewSuggestCommandFactory(mockService, mockHandler) + factory := NewSuggestCommandFactory(mockService, mockHandler, mockGit) command := factory.CreateCommand(translations, cfg) ctx := context.Background() diff --git a/internal/config/config.go b/internal/config/config.go index 7bce3b5..acfc56a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,7 @@ type ( UpdateChangelog bool `json:"update_changelog"` VersionFile string `json:"version_file,omitempty"` VersionPattern string `json:"version_pattern,omitempty"` + AutoFetchTags bool `json:"auto_fetch_tags"` } AIProviderConfig struct { @@ -51,7 +52,7 @@ type ( } VCSConfig struct { - Provider string `json:"provider"` // github or gitlab or whatever you want + Provider string `json:"provider"` // GitHub or gitlab or whatever you want Token string `json:"token,omitempty"` Owner string `json:"owner,omitempty"` Repo string `json:"repo,omitempty"` diff --git a/internal/git/git_service_test.go b/internal/git/git_service_test.go index ec624c5..9457585 100644 --- a/internal/git/git_service_test.go +++ b/internal/git/git_service_test.go @@ -3,14 +3,15 @@ package git import ( "context" "errors" + "fmt" "os" "os/exec" "path/filepath" "strings" "testing" - domainErrors "github.com/thomas-vilte/matecommit/internal/errors" "github.com/stretchr/testify/assert" + domainErrors "github.com/thomas-vilte/matecommit/internal/errors" ) var originalDir string @@ -70,22 +71,18 @@ func TestGitService(t *testing.T) { service := NewGitService() - // Act - Verificar sin cambios staged hasStagedBefore := service.HasStagedChanges(context.Background()) - // Crear y hacer stage de un archivo testFile := filepath.Join("test.txt") if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil { t.Fatalf("Error creando archivo de prueba: %v", err) } - // Stage el archivo cmd := exec.Command("git", "add", "test.txt") if err := cmd.Run(); err != nil { t.Fatalf("Error haciendo stage del archivo: %v", err) } - // Act - Verificar con cambios staged hasStagedAfter := service.HasStagedChanges(context.Background()) // Assert @@ -104,7 +101,6 @@ func TestGitService(t *testing.T) { service := NewGitService() - // Act - Obtener la branch actual (debería ser 'main' por defecto) branchName, err := service.GetCurrentBranch(context.Background()) // Assert @@ -321,9 +317,12 @@ func TestGitService(t *testing.T) { // Act diff, err := service.GetDiff(context.Background()) - // Assert - if err != nil { - t.Errorf("Error obteniendo diff: %v", err) + if err == nil { + t.Error("Expected ErrNoDiff when there are no changes") + } + + if !errors.Is(err, domainErrors.ErrNoDiff) { + t.Errorf("Expected ErrNoDiff, got: %v", err) } if diff != "" { @@ -348,7 +347,6 @@ func TestAddFileToStaging(t *testing.T) { t.Fatalf("Error inesperado: %v", err) } - // verificar staging cmd := exec.Command("git", "diff", "--cached", "--name-status") output, _ := cmd.Output() if !strings.Contains(string(output), "A\t"+testFile) { @@ -363,7 +361,6 @@ func TestAddFileToStaging(t *testing.T) { service := NewGitService() testFile := "deleted.txt" - // Crear y committear archivo if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { return } @@ -374,7 +371,6 @@ func TestAddFileToStaging(t *testing.T) { t.Fatalf("Error al crear commit inicial: %v", err) } - // Eliminar y agregar al staging if err := os.Remove(testFile); err != nil { return } @@ -383,7 +379,6 @@ func TestAddFileToStaging(t *testing.T) { t.Fatalf("Error inesperado: %v", err) } - // Verificar eliminación en staging cmd := exec.Command("git", "diff", "--cached", "--name-status") output, _ := cmd.Output() if !strings.Contains(string(output), "D\t"+testFile) { @@ -534,12 +529,10 @@ func TestGitService_NewMethods(t *testing.T) { service := NewGitService() - // Caso 1: Sin tags tag, err := service.GetLastTag(context.Background()) assert.NoError(t, err) assert.Equal(t, "", tag, "Debería retornar string vacío si no hay tags") - // Caso 2: Con tags createCommitHelper(t, "file1.txt", "Initial commit") err = exec.Command("git", "tag", "-a", "v0.1.0", "-m", "Version 0.1.0").Run() @@ -560,22 +553,18 @@ func TestGitService_NewMethods(t *testing.T) { service := NewGitService() - // Crear historial createCommitHelper(t, "init.txt", "chore: initial commit") _ = exec.Command("git", "tag", "-a", "v1.0.0", "-m", "v1.0.0").Run() createCommitHelper(t, "feat.txt", "feat: amazing feature") createCommitHelper(t, "fix.txt", "fix: critical bug") - // Caso 1: Commits desde v1.0.0 commits, err := service.GetCommitsSinceTag(context.Background(), "v1.0.0") assert.NoError(t, err) assert.Len(t, commits, 2) - // Git log suele devolver en orden cronológico inverso (el más reciente primero) assert.Contains(t, commits[0].Message, "fix: critical bug") assert.Contains(t, commits[1].Message, "feat: amazing feature") - // Caso 2: Sin tag previo (debería traer todos) commits, err = service.GetCommitsSinceTag(context.Background(), "") assert.NoError(t, err) assert.Len(t, commits, 3) // feat, fix, chore @@ -591,7 +580,6 @@ func TestGitService_NewMethods(t *testing.T) { err := service.CreateTag(context.Background(), "v2.0.0", "Release v2.0.0") assert.NoError(t, err) - // Verificar que el tag existe output, _ := exec.Command("git", "tag", "-l", "v2.0.0").Output() assert.Contains(t, string(output), "v2.0.0") }) @@ -602,8 +590,6 @@ func TestGitService_NewMethods(t *testing.T) { service := NewGitService() - // Repositorio vacío (recién inicializado, sin commits) puede dar error o 0 dependiendo de la versión de git/setup - // Vamos a crear al menos uno para asegurar createCommitHelper(t, "one.txt", "one") count, err := service.GetCommitCount(context.Background()) assert.NoError(t, err) @@ -616,11 +602,9 @@ func TestGitService_NewMethods(t *testing.T) { }) t.Run("PushTag", func(t *testing.T) { - // Setup local repo localDir := setupTestRepo(t) defer cleanupTestRepo(t, localDir) - // Setup bare remote repo to simulate origin remoteDir, err := os.MkdirTemp("", "git-remote-test") if err != nil { t.Fatalf("Error creando dir remoto: %v", err) @@ -631,12 +615,10 @@ func TestGitService_NewMethods(t *testing.T) { } }() - // Init bare repo if err := exec.Command("git", "init", "--bare", remoteDir).Run(); err != nil { t.Fatalf("Error iniciando bare repo: %v", err) } - // Add remote to local if err := exec.Command("git", "remote", "add", "origin", remoteDir).Run(); err != nil { t.Fatalf("Error agregando remote: %v", err) } @@ -644,18 +626,368 @@ func TestGitService_NewMethods(t *testing.T) { service := NewGitService() createCommitHelper(t, "code.txt", "ready to release") - // Push antes de tener el tag debería fallar o no hacer nada relevante, primero creamos el tag err = service.CreateTag(context.Background(), "v1.0.0", "Release") assert.NoError(t, err) - // Test PushTag err = service.PushTag(context.Background(), "v1.0.0") assert.NoError(t, err) - // Verificar en el remoto que el tag existe cmd := exec.Command("git", "--git-dir", remoteDir, "tag") out, err := cmd.Output() assert.NoError(t, err) assert.Contains(t, string(out), "v1.0.0") }) } + +func TestGitService_ErrorHandling(t *testing.T) { + t.Run("GetDiff returns ErrNoDiff when no changes", func(t *testing.T) { + tempDir := setupTestRepo(t) + defer cleanupTestRepo(t, tempDir) + + service := NewGitService() + + testFile := "test.txt" + if err := os.WriteFile(testFile, []byte("initial content"), 0644); err != nil { + t.Fatalf("Error creating file: %v", err) + } + cmd := exec.Command("git", "add", "test.txt") + if err := cmd.Run(); err != nil { + t.Fatalf("Error staging file: %v", err) + } + cmd = exec.Command("git", "commit", "-m", "initial commit") + if err := cmd.Run(); err != nil { + t.Fatalf("Error committing: %v", err) + } + + _, err := service.GetDiff(context.Background()) + assert.Error(t, err) + assert.ErrorIs(t, err, domainErrors.ErrNoDiff) + }) + + t.Run("CreateTag with error returns proper AppError with context", func(t *testing.T) { + tempDir := setupTestRepo(t) + defer cleanupTestRepo(t, tempDir) + + service := NewGitService() + + testFile := "test.txt" + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + t.Fatalf("Error creating file: %v", err) + } + cmd := exec.Command("git", "add", "test.txt") + if err := cmd.Run(); err != nil { + t.Fatalf("Error staging: %v", err) + } + cmd = exec.Command("git", "commit", "-m", "initial") + if err := cmd.Run(); err != nil { + t.Fatalf("Error committing: %v", err) + } + + err := service.CreateTag(context.Background(), "v1.0.0", "Release v1.0.0") + assert.NoError(t, err) + + err = service.CreateTag(context.Background(), "v1.0.0", "Duplicate") + assert.Error(t, err) + + var appErr *domainErrors.AppError + assert.True(t, errors.As(err, &appErr)) + assert.Equal(t, domainErrors.TypeGit, appErr.Type) + assert.Equal(t, "v1.0.0", appErr.Context["version"]) + }) + + t.Run("AddFileToStaging with non-existent file returns error with context", func(t *testing.T) { + tempDir := setupTestRepo(t) + defer cleanupTestRepo(t, tempDir) + + service := NewGitService() + + err := service.AddFileToStaging(context.Background(), "non-existent.txt") + assert.Error(t, err) + + var appErr *domainErrors.AppError + assert.True(t, errors.As(err, &appErr)) + assert.Equal(t, domainErrors.TypeGit, appErr.Type) + assert.Equal(t, "non-existent.txt", appErr.Context["file"]) + errMsg := err.Error() + hasExpectedMsg := strings.Contains(errMsg, "did not match any files") || + strings.Contains(errMsg, "no concordó con ningún archivo") + assert.True(t, hasExpectedMsg, "Expected error message about file not matching, got: %s", errMsg) + }) + + t.Run("GetCommitCount returns error with proper wrapping", func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "not-git-repo") + assert.NoError(t, err) + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + t.Fatalf("Error removing temp dir: %v", err) + } + }() + + originalWd, _ := os.Getwd() + defer func() { + if err := os.Chdir(originalWd); err != nil { + t.Fatalf("Error changing working directory: %v", err) + } + }() + + err = os.Chdir(tempDir) + assert.NoError(t, err) + + service := NewGitService() + count, err := service.GetCommitCount(context.Background()) + assert.Error(t, err) + assert.Equal(t, 0, count) + + var appErr *domainErrors.AppError + assert.True(t, errors.As(err, &appErr)) + assert.Equal(t, domainErrors.TypeGit, appErr.Type) + assert.Contains(t, err.Error(), "failed to get commit count") + }) +} + +// TestGitService_ValidateTagExists tests tag validation +func TestGitService_ValidateTagExists(t *testing.T) { + t.Run("ValidateTagExists returns error for non-existent tag", func(t *testing.T) { + tempDir := setupTestRepo(t) + defer cleanupTestRepo(t, tempDir) + + service := NewGitService() + + testFile := "test.txt" + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + t.Fatalf("Error creating file: %v", err) + } + cmd := exec.Command("git", "add", "test.txt") + if err := cmd.Run(); err != nil { + t.Fatalf("Error staging: %v", err) + } + cmd = exec.Command("git", "commit", "-m", "test") + if err := cmd.Run(); err != nil { + t.Fatalf("Error committing: %v", err) + } + + err := service.ValidateTagExists(context.Background(), "v999.999.999") + assert.Error(t, err) + + var appErr *domainErrors.AppError + assert.True(t, errors.As(err, &appErr)) + assert.Equal(t, "v999.999.999", appErr.Context["tag"]) + }) + + t.Run("ValidateTagExists succeeds for existing tag", func(t *testing.T) { + tempDir := setupTestRepo(t) + defer cleanupTestRepo(t, tempDir) + + service := NewGitService() + + testFile := "test.txt" + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + t.Fatalf("Error creating file: %v", err) + } + cmd := exec.Command("git", "add", "test.txt") + if err := cmd.Run(); err != nil { + t.Fatalf("Error staging: %v", err) + } + cmd = exec.Command("git", "commit", "-m", "test") + if err := cmd.Run(); err != nil { + t.Fatalf("Error committing: %v", err) + } + + err := service.CreateTag(context.Background(), "v1.0.0", "Test tag") + assert.NoError(t, err) + + err = service.ValidateTagExists(context.Background(), "v1.0.0") + assert.NoError(t, err) + }) +} + +// TestGitService_GetRecentCommitMessages tests getting recent commit messages +func TestGitService_GetRecentCommitMessages(t *testing.T) { + t.Run("GetRecentCommitMessages returns messages", func(t *testing.T) { + tempDir := setupTestRepo(t) + defer cleanupTestRepo(t, tempDir) + + service := NewGitService() + + commits := []string{"First commit", "Second commit", "Third commit"} + for i, msg := range commits { + testFile := filepath.Join(tempDir, fmt.Sprintf("file%d.txt", i)) + if err := os.WriteFile(testFile, []byte("content"), 0644); err != nil { + t.Fatalf("Error creating file: %v", err) + } + cmd := exec.Command("git", "add", ".") + if err := cmd.Run(); err != nil { + t.Fatalf("Error staging: %v", err) + } + cmd = exec.Command("git", "commit", "-m", msg) + if err := cmd.Run(); err != nil { + t.Fatalf("Error committing: %v", err) + } + } + + messages, err := service.GetRecentCommitMessages(context.Background(), 2) + assert.NoError(t, err) + assert.Len(t, messages, 2) + assert.Contains(t, messages[0], "Third commit") + assert.Contains(t, messages[1], "Second commit") + }) + + t.Run("GetRecentCommitMessages returns error with context when not in repo", func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "not-git-repo") + assert.NoError(t, err) + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + t.Fatalf("Error removing temp dir: %v", err) + } + }() + + originalWd, _ := os.Getwd() + defer func() { + if err := os.Chdir(originalWd); err != nil { + t.Fatalf("Error changing working directory: %v", err) + } + }() + + err = os.Chdir(tempDir) + assert.NoError(t, err) + + service := NewGitService() + messages, err := service.GetRecentCommitMessages(context.Background(), 5) + assert.Error(t, err) + assert.Nil(t, messages) + + var appErr *domainErrors.AppError + assert.True(t, errors.As(err, &appErr)) + assert.Equal(t, domainErrors.TypeGit, appErr.Type) + assert.Equal(t, 5, appErr.Context["count"]) + }) +} + +// TestGitService_GetGitUserInfo tests getting git user configuration +func TestGitService_GetGitUserInfo(t *testing.T) { + t.Run("GetGitUserName returns configured name", func(t *testing.T) { + tempDir := setupTestRepo(t) + defer cleanupTestRepo(t, tempDir) + + service := NewGitService() + name, err := service.GetGitUserName(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "Test User", name) + }) + + t.Run("GetGitUserEmail returns configured email", func(t *testing.T) { + tempDir := setupTestRepo(t) + defer cleanupTestRepo(t, tempDir) + + service := NewGitService() + email, err := service.GetGitUserEmail(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "test@example.com", email) + }) + + t.Run("GetGitUserName returns error with context when not configured", func(t *testing.T) { + tempDir := setupTestRepo(t) + defer cleanupTestRepo(t, tempDir) + + cmd := exec.Command("git", "config", "--local", "--unset", "user.name") + _ = cmd.Run() + cmd = exec.Command("git", "config", "--global", "--unset", "user.name") + _ = cmd.Run() + + cmd = exec.Command("git", "config", "user.name") + _, err := cmd.Output() + if err == nil { + t.Skip("Cannot fully unset git user.name due to global/system config") + } + + service := NewGitService() + name, err := service.GetGitUserName(context.Background()) + if err != nil { + assert.Empty(t, name) + var appErr *domainErrors.AppError + if errors.As(err, &appErr) { + assert.Equal(t, "user.name", appErr.Context["config_key"]) + } + } + }) +} + +func TestGitService_GetTagDate(t *testing.T) { + t.Run("GetTagDate returns date for existing tag", func(t *testing.T) { + tempDir := setupTestRepo(t) + defer cleanupTestRepo(t, tempDir) + + service := NewGitService() + + testFile := "test.txt" + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + t.Fatalf("Error creating file: %v", err) + } + cmd := exec.Command("git", "add", "test.txt") + if err := cmd.Run(); err != nil { + t.Fatalf("Error staging: %v", err) + } + cmd = exec.Command("git", "commit", "-m", "test") + if err := cmd.Run(); err != nil { + t.Fatalf("Error committing: %v", err) + } + + err := service.CreateTag(context.Background(), "v1.0.0", "Release") + assert.NoError(t, err) + + date, err := service.GetTagDate(context.Background(), "v1.0.0") + assert.NoError(t, err) + assert.NotEmpty(t, date) + assert.Len(t, date, 10) + }) + + t.Run("GetTagDate returns error with context for non-existent tag", func(t *testing.T) { + tempDir := setupTestRepo(t) + defer cleanupTestRepo(t, tempDir) + + service := NewGitService() + + date, err := service.GetTagDate(context.Background(), "v999.0.0") + assert.Error(t, err) + assert.Empty(t, date) + + var appErr *domainErrors.AppError + assert.True(t, errors.As(err, &appErr)) + assert.Equal(t, "v999.0.0", appErr.Context["tag"]) + }) +} + +func TestGitService_Push(t *testing.T) { + t.Run("Push returns error when no remote configured", func(t *testing.T) { + tempDir := setupTestRepo(t) + defer cleanupTestRepo(t, tempDir) + + service := NewGitService() + + err := service.Push(context.Background()) + assert.Error(t, err) + + var appErr *domainErrors.AppError + assert.True(t, errors.As(err, &appErr)) + assert.Equal(t, domainErrors.TypeGit, appErr.Type) + assert.Contains(t, err.Error(), "failed to push to remote") + }) +} + +func TestGitService_GetCommitsBetweenTags(t *testing.T) { + t.Run("GetCommitsBetweenTags returns error with tag context", func(t *testing.T) { + tempDir := setupTestRepo(t) + defer cleanupTestRepo(t, tempDir) + + service := NewGitService() + + commits, err := service.GetCommitsBetweenTags(context.Background(), "v1.0.0", "v2.0.0") + assert.Error(t, err) + assert.Nil(t, commits) + + var appErr *domainErrors.AppError + assert.True(t, errors.As(err, &appErr)) + assert.Equal(t, "v1.0.0", appErr.Context["from_tag"]) + assert.Equal(t, "v2.0.0", appErr.Context["to_tag"]) + }) +} diff --git a/internal/i18n/locales/active.en.toml b/internal/i18n/locales/active.en.toml index 049e81e..3cd3d6d 100644 --- a/internal/i18n/locales/active.en.toml +++ b/internal/i18n/locales/active.en.toml @@ -365,6 +365,7 @@ version_calculated = "📦 New version calculated: {{.Version}}" error_updating_app_version = "Error updating app version: {{.Error}}" app_version_updated = "✅ App version updated to {{.Version}}" commit_no_staged = "No changes to commit. Skipping commit step." +error_invalid_branch = "❌ Error: Must be on 'main' or 'master' branch to create releases. {{.Error}}" # Generate Markdown md_version = "Version" diff --git a/internal/models/pr.go b/internal/models/pr.go index 4a1f2c3..1b00d70 100644 --- a/internal/models/pr.go +++ b/internal/models/pr.go @@ -41,6 +41,10 @@ type ( // Commit represents a commit included in the PR. Commit struct { + Hash string + Author string + Email string + Date string Message string } diff --git a/internal/services/release_service_test.go b/internal/services/release_service_test.go index a3ec1b1..596d606 100644 --- a/internal/services/release_service_test.go +++ b/internal/services/release_service_test.go @@ -8,11 +8,11 @@ import ( "strings" "testing" - "github.com/thomas-vilte/matecommit/internal/config" - "github.com/thomas-vilte/matecommit/internal/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/config" + "github.com/thomas-vilte/matecommit/internal/models" ) func TestReleaseService_AnalyzeNextRelease(t *testing.T) { @@ -462,6 +462,7 @@ func TestReleaseService_UpdateAppVersion(t *testing.T) { cmdDir := filepath.Join(dir, "cmd") err := os.MkdirAll(cmdDir, 0755) require.NoError(t, err) + ctx := context.Background() mainGoPath := filepath.Join(cmdDir, "main.go") initialContent := `package main @@ -478,7 +479,7 @@ var ( service := NewReleaseService(nil, WithReleaseConfig(cfg)) - err = service.UpdateAppVersion("v1.1.0") + err = service.UpdateAppVersion(ctx, "v1.1.0") assert.NoError(t, err) newContent, err := os.ReadFile(mainGoPath) @@ -490,6 +491,7 @@ var ( t.Run("should update version with custom pattern", func(t *testing.T) { dir := t.TempDir() versionFile := filepath.Join(dir, "version.go") + ctx := context.Background() initialContent := `package version const CurrentVersion = "0.0.1" @@ -504,7 +506,7 @@ const CurrentVersion = "0.0.1" service := NewReleaseService(nil, WithReleaseConfig(cfg)) - err = service.UpdateAppVersion("v0.0.2") + err = service.UpdateAppVersion(ctx, "v0.0.2") assert.NoError(t, err) newContent, err := os.ReadFile(versionFile) @@ -515,6 +517,7 @@ const CurrentVersion = "0.0.1" t.Run("should fail if pattern not found", func(t *testing.T) { dir := t.TempDir() + ctx := context.Background() versionFile := filepath.Join(dir, "version.go") err := os.WriteFile(versionFile, []byte("package version\n"), 0644) require.NoError(t, err) @@ -522,7 +525,7 @@ const CurrentVersion = "0.0.1" cfg := &config.Config{VersionFile: versionFile} service := NewReleaseService(nil, WithReleaseConfig(cfg)) - err = service.UpdateAppVersion("v1.0.0") + err = service.UpdateAppVersion(ctx, "v1.0.0") assert.Error(t, err) }) } @@ -634,3 +637,755 @@ func TestReleaseService_PrependToChangelog(t *testing.T) { assert.True(t, strings.HasPrefix(string(content), "# Changelog")) }) } + +func TestReleaseService_FindVersionFile_RealScenarios(t *testing.T) { + t.Run("Go project with standard layout", func(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Fatal(err) + } + }() + + goModPath := filepath.Join(dir, "go.mod") + err := os.WriteFile(goModPath, []byte("module test\n"), 0644) + require.NoError(t, err) + + versionDir := filepath.Join(dir, "internal", "version") + err = os.MkdirAll(versionDir, 0755) + require.NoError(t, err) + + versionFile := filepath.Join(versionDir, "version.go") + versionContent := `package version + +const Version = "1.0.0" +` + err = os.WriteFile(versionFile, []byte(versionContent), 0644) + require.NoError(t, err) + + err = os.Chdir(dir) + require.NoError(t, err) + + service := &ReleaseService{} + foundFile, pattern, err := service.FindVersionFile(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "internal/version/version.go", foundFile) + assert.Contains(t, pattern, "Version") + }) + + t.Run("Python project with setup.py", func(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Fatal(err) + } + }() + setupPy := filepath.Join(dir, "setup.py") + setupContent := `from setuptools import setup + +setup( + name="test", + version="0.1.0", +) +` + err := os.WriteFile(setupPy, []byte(setupContent), 0644) + require.NoError(t, err) + + err = os.Chdir(dir) + require.NoError(t, err) + + service := &ReleaseService{} + foundFile, pattern, err := service.FindVersionFile(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "setup.py", foundFile) + assert.Contains(t, pattern, "version") + }) + + t.Run("JavaScript project with package.json", func(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Fatal(err) + } + }() + packageJSON := filepath.Join(dir, "package.json") + packageContent := `{ + "name": "test-package", + "version": "2.3.4", + "description": "Test package" +} +` + err := os.WriteFile(packageJSON, []byte(packageContent), 0644) + require.NoError(t, err) + + err = os.Chdir(dir) + require.NoError(t, err) + + service := &ReleaseService{} + foundFile, pattern, err := service.FindVersionFile(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "package.json", foundFile) + assert.Contains(t, pattern, "version") + }) + + t.Run("Rust project with Cargo.toml", func(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Fatal(err) + } + }() + cargoToml := filepath.Join(dir, "Cargo.toml") + cargoContent := `[package] +name = "test" +version = "0.5.0" +edition = "2021" +` + err := os.WriteFile(cargoToml, []byte(cargoContent), 0644) + require.NoError(t, err) + + err = os.Chdir(dir) + require.NoError(t, err) + + service := &ReleaseService{} + foundFile, pattern, err := service.FindVersionFile(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "Cargo.toml", foundFile) + assert.Contains(t, pattern, "version") + }) + + t.Run("Config-specified version file takes precedence", func(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Fatal(err) + } + }() + customVersion := filepath.Join(dir, "custom_version.go") + content := `package main + +var AppVersion = "3.0.0" +` + err := os.WriteFile(customVersion, []byte(content), 0644) + require.NoError(t, err) + + err = os.Chdir(dir) + require.NoError(t, err) + + cfg := &config.Config{ + VersionFile: customVersion, + VersionPattern: `AppVersion\s*=\s*".*"`, + } + service := &ReleaseService{config: cfg} + + foundFile, pattern, err := service.FindVersionFile(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, customVersion, foundFile) + assert.Equal(t, `AppVersion\s*=\s*".*"`, pattern) + }) + + t.Run("Recursive search finds version file in nested directory", func(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Fatal(err) + } + }() + goModPath := filepath.Join(dir, "go.mod") + err := os.WriteFile(goModPath, []byte("module test\n"), 0644) + require.NoError(t, err) + + pkgVersionDir := filepath.Join(dir, "pkg", "version") + err = os.MkdirAll(pkgVersionDir, 0755) + require.NoError(t, err) + + versionFile := filepath.Join(pkgVersionDir, "version.go") + content := `package version + +const Version = "1.2.3" +` + err = os.WriteFile(versionFile, []byte(content), 0644) + require.NoError(t, err) + + err = os.Chdir(dir) + require.NoError(t, err) + + service := &ReleaseService{} + foundFile, pattern, err := service.FindVersionFile(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "pkg/version/version.go", foundFile) + assert.Contains(t, pattern, "Version") + }) + + t.Run("Error when no version file found", func(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Fatal(err) + } + }() + err := os.Chdir(dir) + require.NoError(t, err) + + service := &ReleaseService{} + _, _, err = service.FindVersionFile(context.Background()) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "could not find version file") + }) +} + +func TestReleaseService_DetectProjectType_RealScenarios(t *testing.T) { + tests := []struct { + name string + files map[string]string + expectedType string + }{ + { + name: "Go project with go.mod", + files: map[string]string{ + "go.mod": "module test", + }, + expectedType: "go", + }, + { + name: "Python project with requirements.txt", + files: map[string]string{ + "requirements.txt": "flask==2.0.0", + }, + expectedType: "python", + }, + { + name: "JavaScript project with package.json", + files: map[string]string{ + "package.json": `{"name": "test"}`, + }, + expectedType: "js", + }, + { + name: "Rust project with Cargo.toml", + files: map[string]string{ + "Cargo.toml": "[package]", + }, + expectedType: "rust", + }, + { + name: "PHP project with composer.json", + files: map[string]string{ + "composer.json": `{"name": "test/package"}`, + }, + expectedType: "php", + }, + { + name: "Ruby project with Gemfile", + files: map[string]string{ + "Gemfile": "source 'https://rubygems.org'", + }, + expectedType: "ruby", + }, + { + name: "Go project detected by .go files", + files: map[string]string{ + "main.go": "package main", + "util.go": "package util", + }, + expectedType: "go", + }, + { + name: "Python project detected by .py files", + files: map[string]string{ + "app.py": "import flask", + "test.py": "import unittest", + }, + expectedType: "python", + }, + { + name: "Unknown project type", + files: map[string]string{}, + expectedType: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Fatal(err) + } + }() + for filename, content := range tt.files { + filePath := filepath.Join(dir, filename) + err := os.WriteFile(filePath, []byte(content), 0644) + require.NoError(t, err) + } + + err := os.Chdir(dir) + require.NoError(t, err) + + service := &ReleaseService{} + projectType := service.detectProjectType() + + assert.Equal(t, tt.expectedType, projectType) + }) + } +} + +func TestReleaseService_ValidateMainBranch_RealScenarios(t *testing.T) { + t.Run("Valid main branch", func(t *testing.T) { + mockGit := new(MockGitService) + service := NewReleaseService(mockGit) + + mockGit.On("GetCurrentBranch", mock.Anything).Return("main", nil) + + err := service.ValidateMainBranch(context.Background()) + + assert.NoError(t, err) + mockGit.AssertExpectations(t) + }) + + t.Run("Valid master branch", func(t *testing.T) { + mockGit := new(MockGitService) + service := NewReleaseService(mockGit) + + mockGit.On("GetCurrentBranch", mock.Anything).Return("master", nil) + + err := service.ValidateMainBranch(context.Background()) + + assert.NoError(t, err) + mockGit.AssertExpectations(t) + }) + + t.Run("Invalid feature branch", func(t *testing.T) { + mockGit := new(MockGitService) + service := NewReleaseService(mockGit) + + mockGit.On("GetCurrentBranch", mock.Anything).Return("feature/new-feature", nil) + + err := service.ValidateMainBranch(context.Background()) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "currently on 'feature/new-feature'") + mockGit.AssertExpectations(t) + }) + + t.Run("Invalid develop branch", func(t *testing.T) { + mockGit := new(MockGitService) + service := NewReleaseService(mockGit) + + mockGit.On("GetCurrentBranch", mock.Anything).Return("develop", nil) + + err := service.ValidateMainBranch(context.Background()) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "currently on 'develop'") + mockGit.AssertExpectations(t) + }) + + t.Run("Git error getting branch", func(t *testing.T) { + mockGit := new(MockGitService) + service := NewReleaseService(mockGit) + + mockGit.On("GetCurrentBranch", mock.Anything).Return("", errors.New("not a git repository")) + + err := service.ValidateMainBranch(context.Background()) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "error getting current branch") + mockGit.AssertExpectations(t) + }) +} + +func TestReleaseService_CommitChangelog_RealScenarios(t *testing.T) { + t.Run("Successfully commit changelog with version file", func(t *testing.T) { + dir := t.TempDir() + versionFile := filepath.Join(dir, "cmd", "main.go") + err := os.MkdirAll(filepath.Dir(versionFile), 0755) + require.NoError(t, err) + + content := `package main +const Version = "1.0.0" +` + err = os.WriteFile(versionFile, []byte(content), 0644) + require.NoError(t, err) + + mockGit := new(MockGitService) + cfg := &config.Config{VersionFile: versionFile} + service := NewReleaseService(mockGit, WithReleaseConfig(cfg)) + + mockGit.On("AddFileToStaging", mock.Anything, versionFile).Return(nil) + mockGit.On("HasStagedChanges", mock.Anything).Return(true) + mockGit.On("CreateCommit", mock.Anything, "chore: update changelog and bump version to v1.1.0").Return(nil) + + err = service.CommitChangelog(context.Background(), "v1.1.0") + + assert.NoError(t, err) + mockGit.AssertExpectations(t) + }) + + t.Run("Error when no staged changes", func(t *testing.T) { + mockGit := new(MockGitService) + service := NewReleaseService(mockGit) + + mockGit.On("HasStagedChanges", mock.Anything).Return(false) + + err := service.CommitChangelog(context.Background(), "v1.1.0") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no staged changes detected") + mockGit.AssertExpectations(t) + }) + + t.Run("Skips missing version file and commits if other files staged", func(t *testing.T) { + mockGit := new(MockGitService) + cfg := &config.Config{VersionFile: "/non/existent/file.go"} + service := NewReleaseService(mockGit, WithReleaseConfig(cfg)) + + mockGit.On("HasStagedChanges", mock.Anything).Return(true) + mockGit.On("CreateCommit", mock.Anything, "chore: update changelog and bump version to v2.0.0").Return(nil) + + err := service.CommitChangelog(context.Background(), "v2.0.0") + + assert.NoError(t, err) + mockGit.AssertExpectations(t) + }) + + t.Run("Error adding file to staging", func(t *testing.T) { + dir := t.TempDir() + versionFile := filepath.Join(dir, "version.txt") + err := os.WriteFile(versionFile, []byte("1.0.0"), 0644) + require.NoError(t, err) + + mockGit := new(MockGitService) + cfg := &config.Config{VersionFile: versionFile} + service := NewReleaseService(mockGit, WithReleaseConfig(cfg)) + + mockGit.On("AddFileToStaging", mock.Anything, versionFile).Return(errors.New("permission denied")) + + err = service.CommitChangelog(context.Background(), "v1.1.0") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to add version file to staging") + mockGit.AssertExpectations(t) + }) + + t.Run("Error creating commit", func(t *testing.T) { + mockGit := new(MockGitService) + service := NewReleaseService(mockGit) + + mockGit.On("HasStagedChanges", mock.Anything).Return(true) + mockGit.On("CreateCommit", mock.Anything, mock.Anything).Return(errors.New("commit failed")) + + err := service.CommitChangelog(context.Background(), "v1.0.0") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to commit changelog and version bump") + mockGit.AssertExpectations(t) + }) +} + +func TestReleaseService_UpdateAppVersion_MultiLanguageRealScenarios(t *testing.T) { + tests := []struct { + name string + fileExt string + initialContent string + versionPattern string + expectedContent string + version string + }{ + { + name: "Go file with const Version", + fileExt: ".go", + initialContent: `package version + +const Version = "1.0.0" +`, + versionPattern: `const\s+Version\s*=\s*"[^"]*"`, + expectedContent: `const Version = "2.0.0"`, + version: "v2.0.0", + }, + { + name: "Python file with __version__", + fileExt: ".py", + initialContent: `"""Version module""" + +__version__ = "0.1.0" +`, + versionPattern: `__version__\s*=\s*"[^"]*"`, + expectedContent: `__version__ = "0.2.0"`, + version: "v0.2.0", + }, + { + name: "JavaScript package.json", + fileExt: ".json", + initialContent: `{ + "name": "test", + "version": "1.2.3", + "description": "Test" +}`, + versionPattern: `"version"\s*:\s*"[^"]*"`, + expectedContent: `"version": "1.3.0"`, + version: "v1.3.0", + }, + { + name: "Rust Cargo.toml", + fileExt: ".toml", + initialContent: `[package] +name = "test" +version = "0.1.0" +edition = "2021"`, + versionPattern: `version\s*=\s*"[^"]*"`, + expectedContent: `version = "0.2.0"`, + version: "v0.2.0", + }, + { + name: "Ruby version.rb", + fileExt: ".rb", + initialContent: `module MyGem + VERSION = "1.0.0" +end`, + versionPattern: `VERSION\s*=\s*"[^"]*"`, + expectedContent: `VERSION = "1.1.0"`, + version: "v1.1.0", + }, + { + name: "PHP composer.json", + fileExt: ".json", + initialContent: `{ + "name": "vendor/package", + "version": "2.0.0" +}`, + versionPattern: `"version"\s*:\s*"[^"]*"`, + expectedContent: `"version": "3.0.0"`, + version: "v3.0.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + versionFile := filepath.Join(dir, "version"+tt.fileExt) + err := os.WriteFile(versionFile, []byte(tt.initialContent), 0644) + require.NoError(t, err) + + cfg := &config.Config{ + VersionFile: versionFile, + VersionPattern: tt.versionPattern, + } + service := &ReleaseService{config: cfg} + + err = service.UpdateAppVersion(context.Background(), tt.version) + assert.NoError(t, err) + + updatedContent, err := os.ReadFile(versionFile) + require.NoError(t, err) + + assert.Contains(t, string(updatedContent), tt.expectedContent) + }) + } +} + +func TestReleaseService_FilterValidCommits_RealScenarios(t *testing.T) { + service := &ReleaseService{} + + tests := []struct { + name string + commits []models.Commit + expected int + }{ + { + name: "All conventional commits", + commits: []models.Commit{ + {Message: "feat: add feature"}, + {Message: "fix: fix bug"}, + {Message: "docs: update docs"}, + }, + expected: 3, + }, + { + name: "Mixed conventional and non-conventional", + commits: []models.Commit{ + {Message: "feat: add feature"}, + {Message: "WIP: work in progress"}, + {Message: "fix: fix bug"}, + {Message: "random commit message"}, + }, + expected: 2, + }, + { + name: "No conventional commits", + commits: []models.Commit{ + {Message: "WIP"}, + {Message: "update stuff"}, + {Message: "changes"}, + }, + expected: 0, + }, + { + name: "Conventional commits with scopes", + commits: []models.Commit{ + {Message: "feat(api): add endpoint"}, + {Message: "fix(ui): fix button"}, + {Message: "refactor(core): improve logic"}, + }, + expected: 3, + }, + { + name: "Breaking changes", + commits: []models.Commit{ + {Message: "feat!: breaking change"}, + {Message: "fix: normal fix"}, + }, + expected: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + valid := service.filterValidCommits(tt.commits) + assert.Len(t, valid, tt.expected) + }) + } +} + +func TestReleaseService_PushChanges_RealScenarios(t *testing.T) { + t.Run("Successfully push changes", func(t *testing.T) { + mockGit := new(MockGitService) + service := NewReleaseService(mockGit) + + mockGit.On("Push", mock.Anything).Return(nil) + + err := service.PushChanges(context.Background()) + + assert.NoError(t, err) + mockGit.AssertExpectations(t) + }) + + t.Run("Error pushing to remote", func(t *testing.T) { + mockGit := new(MockGitService) + service := NewReleaseService(mockGit) + + mockGit.On("Push", mock.Anything).Return(errors.New("failed to push: remote rejected")) + + err := service.PushChanges(context.Background()) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "remote rejected") + mockGit.AssertExpectations(t) + }) +} + +func TestReleaseService_UpdateLocalChangelog_RealScenarios(t *testing.T) { + t.Run("Update changelog with basic notes", func(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Fatal(err) + } + }() + err := os.Chdir(dir) + require.NoError(t, err) + + mockGit := new(MockGitService) + service := NewReleaseService(mockGit) + + release := &models.Release{ + Version: "v1.1.0", + PreviousVersion: "v1.0.0", + } + + notes := &models.ReleaseNotes{ + Title: "Version 1.1.0", + Summary: "This release includes 2 new features", + Highlights: []string{ + "Improved performance", + "Added new API endpoint", + }, + } + + mockGit.On("GetTagDate", mock.Anything, "v1.1.0").Return("2025-01-15", nil) + mockGit.On("GetRepoInfo", mock.Anything).Return("user", "repo", "github", nil) + + err = service.UpdateLocalChangelog(release, notes) + + assert.NoError(t, err) + + changelogPath := filepath.Join(dir, "CHANGELOG.md") + content, err := os.ReadFile(changelogPath) + require.NoError(t, err) + + assert.Contains(t, string(content), "## [v1.1.0]") + assert.Contains(t, string(content), "2025-01-15") + assert.Contains(t, string(content), "This release includes 2 new features") + assert.Contains(t, string(content), "Improved performance") + mockGit.AssertExpectations(t) + }) + + t.Run("Prepend to existing changelog", func(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Fatal(err) + } + }() + changelogPath := filepath.Join(dir, "CHANGELOG.md") + existingContent := `# Changelog + +## [v1.0.0] - 2025-01-01 + +Initial release +` + err := os.WriteFile(changelogPath, []byte(existingContent), 0644) + require.NoError(t, err) + + err = os.Chdir(dir) + require.NoError(t, err) + + mockGit := new(MockGitService) + service := NewReleaseService(mockGit) + + release := &models.Release{ + Version: "v1.1.0", + PreviousVersion: "v1.0.0", + } + + notes := &models.ReleaseNotes{ + Title: "Version 1.1.0", + Summary: "Bug fixes and improvements", + } + + mockGit.On("GetTagDate", mock.Anything, "v1.1.0").Return("2025-01-15", nil) + mockGit.On("GetRepoInfo", mock.Anything).Return("", "", "", errors.New("not a repo")) + + err = service.UpdateLocalChangelog(release, notes) + + assert.NoError(t, err) + + content, err := os.ReadFile(changelogPath) + require.NoError(t, err) + + contentStr := string(content) + assert.Contains(t, contentStr, "## [v1.1.0]") + assert.Contains(t, contentStr, "## [v1.0.0]") + v110Pos := strings.Index(contentStr, "## [v1.1.0]") + v100Pos := strings.Index(contentStr, "## [v1.0.0]") + assert.Less(t, v110Pos, v100Pos, "New version should come before old version") + mockGit.AssertExpectations(t) + }) +} From dd4e91065815c714dae0bb573c1946a86e7af6c7 Mon Sep 17 00:00:00 2001 From: Thomas Vilte Date: Mon, 22 Dec 2025 20:37:15 -0300 Subject: [PATCH 3/4] feat(release): enforce main branch for release operations --- internal/commands/release/create_test.go | 20 +++- internal/commands/release/generate_test.go | 8 +- internal/commands/release/mocks.go | 26 +++- internal/commands/release/preview_test.go | 7 +- internal/commands/release/publish_test.go | 10 +- internal/errors/errors_test.go | 132 +++++++++++++++++++++ internal/i18n/locales/active.es.toml | 1 + internal/services/mocks.go | 10 ++ 8 files changed, 199 insertions(+), 15 deletions(-) create mode 100644 internal/errors/errors_test.go diff --git a/internal/commands/release/create_test.go b/internal/commands/release/create_test.go index 7cd83cc..20dbc41 100644 --- a/internal/commands/release/create_test.go +++ b/internal/commands/release/create_test.go @@ -7,11 +7,11 @@ import ( "errors" "testing" - "github.com/thomas-vilte/matecommit/internal/models" - "github.com/thomas-vilte/matecommit/internal/i18n" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/models" "github.com/urfave/cli/v3" ) @@ -63,6 +63,7 @@ func TestCreateCommand_Success(t *testing.T) { } mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("CreateTag", mock.Anything, "v1.1.0", "Release v1.1.0\n\nSummary of release").Return(nil) @@ -85,6 +86,7 @@ func TestCreateCommand_WithVersionOverride(t *testing.T) { Title: "Release v2.0.0", } + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, mock.MatchedBy(func(r *models.Release) bool { @@ -103,6 +105,7 @@ func TestCreateCommand_AutoConfirm(t *testing.T) { release := &models.Release{Version: "v1.0.1"} notes := &models.ReleaseNotes{Title: "Fix"} + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) @@ -117,6 +120,7 @@ func TestCreateCommand_Cancelled(t *testing.T) { release := &models.Release{Version: "v1.0.1"} notes := &models.ReleaseNotes{Title: "Fix"} + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) @@ -128,6 +132,7 @@ func TestCreateCommand_Cancelled(t *testing.T) { func TestCreateCommand_AnalyzeError(t *testing.T) { mockService := new(MockReleaseService) + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return((*models.Release)(nil), errors.New("git error")) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) @@ -140,6 +145,7 @@ func TestCreateCommand_AnalyzeError(t *testing.T) { func TestCreateCommand_GenerateNotesError(t *testing.T) { mockService := new(MockReleaseService) release := &models.Release{Version: "v1.0.0"} + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return((*models.ReleaseNotes)(nil), errors.New("ai error")) @@ -156,6 +162,7 @@ func TestCreateCommand_CreateTagError(t *testing.T) { release := &models.Release{Version: "v1.0.0"} notes := &models.ReleaseNotes{Title: "Title"} + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) @@ -173,12 +180,12 @@ func TestCreateCommand_WithPublish(t *testing.T) { release := &models.Release{Version: "v1.0.0"} notes := &models.ReleaseNotes{Title: "Title"} + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("CreateTag", mock.Anything, "v1.0.0", mock.Anything).Return(nil) - // Expect PublishRelease with buildBinaries=true (default) mockService.On("PublishRelease", mock.Anything, release, notes, false, true).Return(nil) err := runCreateTest(t, "y\n", []string{"--publish"}, mockService) @@ -192,12 +199,12 @@ func TestCreateCommand_WithPublishDraft(t *testing.T) { release := &models.Release{Version: "v1.0.0"} notes := &models.ReleaseNotes{Title: "Title"} + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("CreateTag", mock.Anything, "v1.0.0", mock.Anything).Return(nil) - // Expect PublishRelease with draft=true and buildBinaries=true (default) mockService.On("PublishRelease", mock.Anything, release, notes, true, true).Return(nil) err := runCreateTest(t, "y\n", []string{"--publish", "--draft"}, mockService) @@ -211,12 +218,12 @@ func TestCreateCommand_PublishError(t *testing.T) { release := &models.Release{Version: "v1.0.0"} notes := &models.ReleaseNotes{Title: "Title"} + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("CreateTag", mock.Anything, "v1.0.0", mock.Anything).Return(nil) - // Expect PublishRelease error mockService.On("PublishRelease", mock.Anything, release, notes, false, true).Return(errors.New("publish error")) err := runCreateTest(t, "y\n", []string{"--publish"}, mockService) @@ -229,12 +236,13 @@ func TestCreateCommand_WithChangelog(t *testing.T) { release := &models.Release{Version: "v1.0.0"} notes := &models.ReleaseNotes{Title: "Title"} + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("UpdateLocalChangelog", release, notes).Return(nil) - mockService.On("UpdateAppVersion", "v1.0.0").Return(nil) + mockService.On("UpdateAppVersion", mock.Anything, "v1.0.0").Return(nil) mockService.On("CommitChangelog", mock.Anything, "v1.0.0").Return(nil) mockService.On("PushChanges", mock.Anything).Return(nil) diff --git a/internal/commands/release/generate_test.go b/internal/commands/release/generate_test.go index 62cc64c..25a7813 100644 --- a/internal/commands/release/generate_test.go +++ b/internal/commands/release/generate_test.go @@ -7,11 +7,11 @@ import ( "path/filepath" "testing" - "github.com/thomas-vilte/matecommit/internal/models" - "github.com/thomas-vilte/matecommit/internal/i18n" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/models" "github.com/urfave/cli/v3" ) @@ -55,6 +55,7 @@ func TestGenerateCommand_Success(t *testing.T) { Highlights: []string{"Highlight 1"}, } + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) @@ -75,6 +76,7 @@ func TestGenerateCommand_Success(t *testing.T) { func TestGenerateCommand_AnalyzeError(t *testing.T) { mockService := new(MockReleaseService) + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return((*models.Release)(nil), errors.New("git error")) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) @@ -88,6 +90,7 @@ func TestGenerateCommand_AnalyzeError(t *testing.T) { func TestGenerateCommand_GenerateError(t *testing.T) { mockService := new(MockReleaseService) release := &models.Release{Version: "v1.0.0"} + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return((*models.ReleaseNotes)(nil), errors.New("ai error")) @@ -104,6 +107,7 @@ func TestGenerateCommand_WriteError(t *testing.T) { release := &models.Release{Version: "v1.0.0"} notes := &models.ReleaseNotes{Title: "Title"} + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) diff --git a/internal/commands/release/mocks.go b/internal/commands/release/mocks.go index c46cce9..27eb85e 100644 --- a/internal/commands/release/mocks.go +++ b/internal/commands/release/mocks.go @@ -3,8 +3,8 @@ package release import ( "context" - "github.com/thomas-vilte/matecommit/internal/models" "github.com/stretchr/testify/mock" + "github.com/thomas-vilte/matecommit/internal/models" ) type MockReleaseService struct { @@ -75,8 +75,8 @@ func (m *MockReleaseService) PushChanges(ctx context.Context) error { return args.Error(0) } -func (m *MockReleaseService) UpdateAppVersion(version string) error { - args := m.Called(version) +func (m *MockReleaseService) UpdateAppVersion(ctx context.Context, version string) error { + args := m.Called(ctx, version) return args.Error(0) } @@ -169,3 +169,23 @@ func (m *MockGitService) GetRecentCommitMessages(ctx context.Context, count int) args := m.Called(ctx, count) return args.Get(0).([]string), args.Error(1) } + +func (m *MockReleaseService) ValidateMainBranch(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func (m *MockGitService) FetchTags(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func (m *MockGitService) ValidateGitConfig(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func (m *MockGitService) ValidateTagExists(ctx context.Context, tag string) error { + args := m.Called(ctx, tag) + return args.Error(0) +} diff --git a/internal/commands/release/preview_test.go b/internal/commands/release/preview_test.go index d407720..94464a5 100644 --- a/internal/commands/release/preview_test.go +++ b/internal/commands/release/preview_test.go @@ -5,11 +5,11 @@ import ( "errors" "testing" - "github.com/thomas-vilte/matecommit/internal/models" - "github.com/thomas-vilte/matecommit/internal/i18n" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/models" "github.com/urfave/cli/v3" ) @@ -50,6 +50,7 @@ func TestPreviewCommand_Success(t *testing.T) { Highlights: []string{"highlight"}, } + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) @@ -62,6 +63,7 @@ func TestPreviewCommand_Success(t *testing.T) { func TestPreviewCommand_AnalyzeError(t *testing.T) { mockService := new(MockReleaseService) + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return((*models.Release)(nil), errors.New("git error")) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) @@ -75,6 +77,7 @@ func TestPreviewCommand_AnalyzeError(t *testing.T) { func TestPreviewCommand_GenerateError(t *testing.T) { mockService := new(MockReleaseService) release := &models.Release{Version: "v1.0.0"} + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return((*models.ReleaseNotes)(nil), errors.New("ai error")) diff --git a/internal/commands/release/publish_test.go b/internal/commands/release/publish_test.go index af65e98..e223e37 100644 --- a/internal/commands/release/publish_test.go +++ b/internal/commands/release/publish_test.go @@ -5,11 +5,11 @@ import ( "errors" "testing" - "github.com/thomas-vilte/matecommit/internal/models" - "github.com/thomas-vilte/matecommit/internal/i18n" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/i18n" + "github.com/thomas-vilte/matecommit/internal/models" "github.com/urfave/cli/v3" ) @@ -53,6 +53,7 @@ func TestPublishCommand_Success(t *testing.T) { Summary: "Major release", } + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) @@ -78,6 +79,7 @@ func TestPublishCommand_WithDraftFlag(t *testing.T) { Summary: "Major release", } + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) @@ -103,6 +105,7 @@ func TestPublishCommand_WithVersionOverride(t *testing.T) { Summary: "Major release", } + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, mock.MatchedBy(func(r *models.Release) bool { @@ -121,6 +124,7 @@ func TestPublishCommand_WithVersionOverride(t *testing.T) { func TestPublishCommand_AnalyzeError(t *testing.T) { mockService := new(MockReleaseService) + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return((*models.Release)(nil), errors.New("analyze failed")) err := runPublishTest(t, []string{}, mockService) @@ -139,6 +143,7 @@ func TestPublishCommand_GenerateNotesError(t *testing.T) { VersionBump: models.MinorBump, } + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return((*models.ReleaseNotes)(nil), errors.New("generate failed")) @@ -164,6 +169,7 @@ func TestPublishCommand_PublishError(t *testing.T) { Summary: "Major release", } + mockService.On("ValidateMainBranch", mock.Anything, mock.Anything).Return(nil) mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go new file mode 100644 index 0000000..b2190b5 --- /dev/null +++ b/internal/errors/errors_test.go @@ -0,0 +1,132 @@ +package errors + +import ( + "errors" + "testing" +) + +func TestAppError_WithError(t *testing.T) { + baseErr := errors.New("original error") + appErr := ErrGetDiff.WithError(baseErr) + + if appErr.Err != baseErr { + t.Errorf("Expected underlying error to be %v, got %v", baseErr, appErr.Err) + } + + if appErr.Type != TypeGit { + t.Errorf("Expected type %s, got %s", TypeGit, appErr.Type) + } +} + +func TestAppError_WithContext(t *testing.T) { + appErr := ErrAddFile.WithContext("file", "test.txt").WithContext("stderr", "file not found") + + if appErr.Context["file"] != "test.txt" { + t.Errorf("Expected file context 'test.txt', got %v", appErr.Context["file"]) + } + + if appErr.Context["stderr"] != "file not found" { + t.Errorf("Expected stderr context 'file not found', got %v", appErr.Context["stderr"]) + } +} + +func TestAppError_Error_Format(t *testing.T) { + tests := []struct { + name string + err *AppError + contains []string + }{ + { + name: "Simple error without underlying error", + err: ErrNoChanges, + contains: []string{ + "GIT", + "no staged changes detected", + }, + }, + { + name: "Error with underlying error", + err: ErrGetBranch.WithError(errors.New("exit status 1")), + contains: []string{ + "GIT", + "failed to get current branch", + "exit status 1", + }, + }, + { + name: "Error with context including stderr", + err: ErrAddFile.WithError(errors.New("exit status 128")). + WithContext("file", "test.go"). + WithContext("stderr", "did not match any files"), + contains: []string{ + "GIT", + "failed to add file to staging", + "exit status 128", + "did not match any files", + }, + }, + { + name: "Error with multiple context fields", + err: ErrGetDiff.WithError(errors.New("command failed")). + WithContext("diff_type", "staged"). + WithContext("stderr", "repository not found"), + contains: []string{ + "GIT", + "failed to get diff", + "command failed", + "repository not found", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errMsg := tt.err.Error() + for _, substr := range tt.contains { + if !contains(errMsg, substr) { + t.Errorf("Expected error message to contain %q, got: %s", substr, errMsg) + } + } + }) + } +} + +func TestAppError_Unwrap(t *testing.T) { + baseErr := errors.New("base error") + appErr := ErrCreateCommit.WithError(baseErr) + + unwrapped := appErr.Unwrap() + if unwrapped != baseErr { + t.Errorf("Expected unwrapped error to be %v, got %v", baseErr, unwrapped) + } + + // Test errors.Is functionality + if !errors.Is(appErr, baseErr) { + t.Error("errors.Is should work with AppError") + } +} + +func TestAppError_ChainedContext(t *testing.T) { + appErr := ErrCreateTag. + WithError(errors.New("tag exists")). + WithContext("version", "v1.0.0"). + WithContext("remote", "origin") + + if appErr.Context["version"] != "v1.0.0" { + t.Errorf("Expected version context, got %v", appErr.Context["version"]) + } + + if appErr.Context["remote"] != "origin" { + t.Errorf("Expected remote context, got %v", appErr.Context["remote"]) + } + + // Ensure we didn't modify the original error + if ErrCreateTag.Context != nil { + t.Error("Original error should not have context") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && (s[:len(substr)] == substr || contains(s[1:], substr)))) +} diff --git a/internal/i18n/locales/active.es.toml b/internal/i18n/locales/active.es.toml index 65f6ced..7e14fb4 100644 --- a/internal/i18n/locales/active.es.toml +++ b/internal/i18n/locales/active.es.toml @@ -335,6 +335,7 @@ files_changed = "Archivos modificados" insertions = "Líneas agregadas" deletions = "Líneas eliminadas" warning_enrich_context = "No se pudo enriquecer el contexto del release: {Error}" +error_invalid_branch = "❌ Error: Must be on 'main' or 'master' branch to create releases. {{.Error}}" # Preview preview_usage = "Ver vista previa del siguiente release sin crearlo" diff --git a/internal/services/mocks.go b/internal/services/mocks.go index 87e71e1..dd9387c 100644 --- a/internal/services/mocks.go +++ b/internal/services/mocks.go @@ -136,6 +136,16 @@ func (m *MockGitService) Push(ctx context.Context) error { return args.Error(0) } +func (m *MockGitService) FetchTags(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func (m *MockGitService) ValidateTagExists(ctx context.Context, tag string) error { + args := m.Called(ctx, tag) + return args.Error(0) +} + func (m *MockAIProvider) GenerateSuggestions(ctx context.Context, info models.CommitInfo, count int) ([]models.CommitSuggestion, error) { args := m.Called(ctx, info, count) return args.Get(0).([]models.CommitSuggestion), args.Error(1) From 2f61255ea69e24a4360b7ef42f95b4aad87f18a9 Mon Sep 17 00:00:00 2001 From: Thomas Vilte Date: Tue, 23 Dec 2025 00:22:36 -0300 Subject: [PATCH 4/4] feat(release): enhance version file detection and validation --- internal/services/release_service.go | 407 +++++++++++++++------- internal/services/release_service_test.go | 88 +++++ 2 files changed, 375 insertions(+), 120 deletions(-) diff --git a/internal/services/release_service.go b/internal/services/release_service.go index 67eaf33..91b5f99 100644 --- a/internal/services/release_service.go +++ b/internal/services/release_service.go @@ -19,6 +19,26 @@ import ( "golang.org/x/mod/semver" ) +const ( + maxSearchDepth = 5 + maxFilesToScan = 100 +) + +var ignoreDirs = map[string]bool{ + ".git": true, + "node_modules": true, + "vendor": true, + "target": true, + "dist": true, + "build": true, + ".next": true, + ".nuxt": true, + "__pycache__": true, + ".pytest_cache": true, + "venv": true, + ".venv": true, +} + // releaseGitService defines only the methods needed by ReleaseService. type releaseGitService interface { GetLastTag(ctx context.Context) (string, error) @@ -43,6 +63,12 @@ type ReleaseService struct { notesGen ports.ReleaseNotesGenerator depAnalyzer *dependency.AnalyzerRegistry config *config.Config + + versionFileCache struct { + file string + pattern string + lang string + } } type ReleaseOption func(*ReleaseService) @@ -758,24 +784,17 @@ func (s *ReleaseService) UpdateAppVersion(ctx context.Context, version string) e return nil } -var commonVersionPatterns = []struct { - name string - pattern string -}{ - {"const Version", `const\s+Version\s*=\s*"([^"]+)"`}, - {"var Version", `var\s+Version\s*=\s*"([^"]+)"`}, - {"const Version", `const\s+Version\s*=\s*([\d.]+)`}, - {"var Version", `var\s+Version\s*=\s*([\d.]+)`}, - {"Version:", `Version:\s*"([^"]+)"`}, - {"Version =", `Version\s*=\s*"([^"]+)"`}, - {"CurrentVersion", `CurrentVersion\s*=\s*"([^"]+)"`}, - {"AppVersion", `AppVersion\s*=\s*"([^"]+)"`}, - {"VERSION", `VERSION\s*=\s*"([^"]+)"`}, -} - func (s *ReleaseService) FindVersionFile(ctx context.Context) (string, string, error) { log := logger.FromContext(ctx) + if s.versionFileCache.file != "" && s.versionFileCache.pattern != "" { + log.Debug("using cached version file", + "file", s.versionFileCache.file, + "pattern", s.versionFileCache.pattern, + ) + return s.versionFileCache.file, s.versionFileCache.pattern, nil + } + if s.config != nil && s.config.VersionFile != "" { pattern := s.config.VersionPattern if pattern == "" { @@ -789,6 +808,10 @@ func (s *ReleaseService) FindVersionFile(ctx context.Context) (string, string, e log.Debug("using configured version file", "file", s.config.VersionFile, "pattern", pattern) + + s.versionFileCache.file = s.config.VersionFile + s.versionFileCache.pattern = pattern + return s.config.VersionFile, pattern, nil } @@ -812,7 +835,12 @@ func (s *ReleaseService) FindVersionFile(ctx context.Context) (string, string, e log.Debug("version file found automatically", "file", filePath, "pattern", pattern, - "language", projectType) + "language", projectType, + ) + + s.versionFileCache.file = filePath + s.versionFileCache.pattern = pattern + s.versionFileCache.lang = projectType return filePath, pattern, nil } } @@ -823,7 +851,13 @@ func (s *ReleaseService) FindVersionFile(ctx context.Context) (string, string, e if err == nil && foundFile != "" { log.Debug("version file found recursively", "file", foundFile, - "pattern", foundPattern) + "pattern", foundPattern, + ) + + s.versionFileCache.file = foundFile + s.versionFileCache.pattern = foundPattern + s.versionFileCache.lang = projectType + return foundFile, foundPattern, nil } @@ -868,73 +902,211 @@ func (s *ReleaseService) validateVersionIncrement(oldVersion, newVersion string) return nil } -func (s *ReleaseService) detectPatternInFile(filePath string) (string, error) { - content, err := os.ReadFile(filePath) - if err != nil { - return "", err - } - - fileContent := string(content) - - lang := detectLanguageFromFile(filePath) +func (s *ReleaseService) validateVersionString(versionStr string) error { + cleanVersion := strings.TrimPrefix(versionStr, "v") - for _, patternInfo := range commonVersionPatterns { - re, err := regexp.Compile(patternInfo.pattern) - if err != nil { - continue - } - if re.MatchString(fileContent) { - adjustedPattern := s.adjustPatternForReplacement(patternInfo.pattern, fileContent, lang) - return adjustedPattern, nil - } + if !semver.IsValid("v" + cleanVersion) { + return fmt.Errorf("invalid semver format: %s", versionStr) } - return "", fmt.Errorf("no version pattern found in file") + return nil } -func (s *ReleaseService) adjustPatternForReplacement(basePattern, content, lang string) string { - patterns := map[string][]string{ - "go": { - `const\s+Version\s*=\s*"[^"]*"`, - `var\s+Version\s*=\s*"[^"]*"`, - `Version:\s*"[^"]*"`, +type versionPatternInfo struct { + name string + detectionPattern string + replacementPattern string +} + +var consolidatedVersionPatterns = map[string][]versionPatternInfo{ + "go": { + { + name: "const Version", + detectionPattern: `const\s+Version\s*=\s*"([^"]+)"`, + replacementPattern: `const\s+Version\s*=\s*"[^"]*"`, + }, + { + name: "var Version", + detectionPattern: `var\s+Version\s*=\s*"([^"]+)"`, + replacementPattern: `var\s+Version\s*=\s*"[^"]*"`, + }, + { + name: "const Version unquoted", + detectionPattern: `const\s+Version\s*=\s*([\d.]+)`, + replacementPattern: `const\s+Version\s*=\s*[\d.]+`, + }, + { + name: "var Version unquoted", + detectionPattern: `var\s+Version\s*=\s*([\d.]+)`, + replacementPattern: `var\s+Version\s*=\s*[\d.]+`, + }, + { + name: "Version:", + detectionPattern: `Version:\s*"([^"]+)"`, + replacementPattern: `Version:\s*"[^"]*"`, + }, + { + name: "Version =", + detectionPattern: `Version\s*=\s*"([^"]+)"`, + replacementPattern: `Version\s*=\s*"[^"]*"`, + }, + }, + "python": { + { + name: "__version__ double quotes", + detectionPattern: `__version__\s*=\s*"([^"]+)"`, + replacementPattern: `__version__\s*=\s*"[^"]*"`, + }, + { + name: "__version__ single quotes", + detectionPattern: `__version__\s*=\s*'([^']+)'`, + replacementPattern: `__version__\s*=\s*'[^']*'`, + }, + { + name: "version double quotes", + detectionPattern: `version\s*=\s*"([^"]+)"`, + replacementPattern: `version\s*=\s*"[^"]*"`, + }, + { + name: "version any quotes", + detectionPattern: `version\s*=\s*['"]([^'"]+)['"]`, + replacementPattern: `version\s*=\s*['"][^'"]*['"]`, + }, + { + name: "VERSION", + detectionPattern: `VERSION\s*=\s*"([^"]+)"`, + replacementPattern: `VERSION\s*=\s*"[^"]*"`, + }, + }, + "js": { + { + name: "version in JSON", + detectionPattern: `"version"\s*:\s*"([^"]+)"`, + replacementPattern: `"version"\s*:\s*"[^"]*"`, + }, + { + name: "version in JSON single quotes", + detectionPattern: `'version'\s*:\s*'([^']+)'`, + replacementPattern: `'version'\s*:\s*'[^']*'`, + }, + { + name: "export const version", + detectionPattern: `export\s+const\s+version\s*=\s*"([^"]+)"`, + replacementPattern: `export\s+const\s+version\s*=\s*"[^"]*"`, + }, + { + name: "export const version single quotes", + detectionPattern: `export\s+const\s+version\s*=\s*'([^']+)'`, + replacementPattern: `export\s+const\s+version\s*=\s*'[^']*'`, + }, + }, + "rust": { + { + name: "version in TOML", + detectionPattern: `version\s*=\s*"([^"]+)"`, + replacementPattern: `version\s*=\s*"[^"]*"`, + }, + }, + "java": { + { + name: "version in XML", + detectionPattern: `([^<]+)`, + replacementPattern: `[^<]+`, }, - "python": { - `__version__\s*=\s*"[^"]*"`, - `__version__\s*=\s*'[^']*'`, - `version\s*=\s*"[^"]*"`, + { + name: "version in properties", + detectionPattern: `version\s*=\s*['"]([^'"]+)['"]`, + replacementPattern: `version\s*=\s*['"][^'"]*['"]`, }, - "js": { - `"version"\s*:\s*"[^"]*"`, - `'version'\s*:\s*'[^']*'`, + }, + "csharp": { + { + name: "AssemblyVersion", + detectionPattern: `AssemblyVersion\s*\(\s*"([^"]+)"`, + replacementPattern: `AssemblyVersion\s*\(\s*"[^"]*"`, }, - "rust": { - `version\s*=\s*"[^"]*"`, + { + name: "Version in XML", + detectionPattern: `([^<]+)`, + replacementPattern: `[^<]+`, }, - "java": { - `[^<]+`, + { + name: "version in JSON", + detectionPattern: `"version"\s*:\s*"([^"]+)"`, + replacementPattern: `"version"\s*:\s*"[^"]*"`, }, - "csharp": { - `AssemblyVersion\s*\(\s*"[^"]*"`, - `[^<]+`, + }, + "php": { + { + name: "version in JSON", + detectionPattern: `"version"\s*:\s*"([^"]+)"`, + replacementPattern: `"version"\s*:\s*"[^"]*"`, }, - "php": { - `"version"\s*:\s*"[^"]*"`, + { + name: "const VERSION", + detectionPattern: `const\s+VERSION\s*=\s*['"]([^'"]+)['"]`, + replacementPattern: `const\s+VERSION\s*=\s*['"][^'"]*['"]`, }, - "ruby": { - `VERSION\s*=\s*['"][^'"]*['"]`, + }, + "ruby": { + { + name: "VERSION", + detectionPattern: `VERSION\s*=\s*['"]([^'"]+)['"]`, + replacementPattern: `VERSION\s*=\s*['"][^'"]*['"]`, }, + { + name: ".version", + detectionPattern: `\.version\s*=\s*['"]([^'"]+)['"]`, + replacementPattern: `\.version\s*=\s*['"][^'"]*['"]`, + }, + }, +} + +func (s *ReleaseService) detectPatternInFile(filePath string) (string, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return "", err } - if langPatterns, ok := patterns[lang]; ok { - for _, pattern := range langPatterns { - re := regexp.MustCompile(pattern) - if re.MatchString(content) { - return pattern + fileContent := string(content) + lang := detectLanguageFromFile(filePath) + + if patterns, ok := consolidatedVersionPatterns[lang]; ok { + for _, patternInfo := range patterns { + re, err := regexp.Compile(patternInfo.detectionPattern) + if err != nil { + continue + } + if re.MatchString(fileContent) { + matches := re.FindStringSubmatch(fileContent) + if len(matches) > 1 { + if err := s.validateVersionString(matches[1]); err != nil { + continue + } + } + return patternInfo.replacementPattern, nil } } } - return basePattern + for _, patterns := range consolidatedVersionPatterns { + for _, patternInfo := range patterns { + re, err := regexp.Compile(patternInfo.detectionPattern) + if err != nil { + continue + } + if re.MatchString(fileContent) { + matches := re.FindStringSubmatch(fileContent) + if len(matches) > 1 { + if err := s.validateVersionString(matches[1]); err != nil { + continue + } + } + return patternInfo.replacementPattern, nil + } + } + } + + return "", fmt.Errorf("no version pattern found in file") } func (s *ReleaseService) searchVersionFileRecursive(lang string) (string, string, error) { @@ -970,15 +1142,35 @@ func (s *ReleaseService) searchVersionFileRecursive(lang string) (string, string exts = []string{""} } + filesScanned := 0 + for _, dir := range dirs { + var foundPath string + var foundPattern string + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } + + if filesScanned >= maxFilesToScan { + return filepath.SkipDir + } + if info.IsDir() { + if ignoreDirs[info.Name()] { + return filepath.SkipDir + } + + depth := len(strings.Split(path, string(os.PathSeparator))) + if depth > maxSearchDepth { + return filepath.SkipDir + } return nil } + filesScanned++ + hasValidExt := false for _, ext := range exts { if ext == "" || strings.HasSuffix(path, ext) { @@ -995,6 +1187,8 @@ func (s *ReleaseService) searchVersionFileRecursive(lang string) (string, string path == "package.json" || path == "Cargo.toml" || path == "setup.py" { pattern, err := s.detectPatternInFileForLanguage(path, lang) if err == nil && pattern != "" { + foundPath = path + foundPattern = pattern return fmt.Errorf("found: %s", path) } } @@ -1002,15 +1196,29 @@ func (s *ReleaseService) searchVersionFileRecursive(lang string) (string, string }) if err != nil && strings.HasPrefix(err.Error(), "found: ") { - foundPath := strings.TrimPrefix(err.Error(), "found: ") - pattern, _ := s.detectPatternInFileForLanguage(foundPath, lang) - return foundPath, pattern, nil + return foundPath, foundPattern, nil } } return "", "", fmt.Errorf("version file not found") } +func (s *ReleaseService) adjustPatternForReplacement(basePattern, content, lang string) string { + patterns, ok := consolidatedVersionPatterns[lang] + if !ok { + return basePattern + } + + for _, patternInfo := range patterns { + re := regexp.MustCompile(patternInfo.detectionPattern) + if re.MatchString(content) { + return patternInfo.replacementPattern + } + } + + return basePattern +} + func (s *ReleaseService) detectProjectType() string { indicators := map[string][]string{ "go": {"go.mod", "Gopkg.toml", "glide.yaml"}, @@ -1108,53 +1316,6 @@ var versionFilesByLanguage = map[string][]string{ }, } -var versionPatternsByLanguage = map[string][]struct { - name string - pattern string -}{ - "go": { - {"const Version", `const\s+Version\s*=\s*"([^"]+)"`}, - {"var Version", `var\s+Version\s*=\s*"([^"]+)"`}, - {"const Version", `const\s+Version\s*=\s*([\d.]+)`}, - {"var Version", `var\s+Version\s*=\s*([\d.]+)`}, - {"Version:", `Version:\s*"([^"]+)"`}, - {"Version =", `Version\s*=\s*"([^"]+)"`}, - }, - "python": { - {"__version__", `__version__\s*=\s*"([^"]+)"`}, - {"__version__", `__version__\s*=\s*['"]([^'"]+)['"]`}, - {"version", `version\s*=\s*"([^"]+)"`}, - {"version", `version\s*=\s*['"]([^'"]+)['"]`}, - {"VERSION", `VERSION\s*=\s*"([^"]+)"`}, - }, - "js": { - {"version", `"version"\s*:\s*"([^"]+)"`}, - {"version", `'version'\s*:\s*'([^']+)'`}, - {"export const version", `export\s+const\s+version\s*=\s*"([^"]+)"`}, - {"export const version", `export\s+const\s+version\s*=\s*'([^']+)'`}, - }, - "rust": { - {"version", `version\s*=\s*"([^"]+)"`}, - }, - "java": { - {"version", `([^<]+)`}, - {"version", `version\s*=\s*['"]([^'"]+)['"]`}, - }, - "csharp": { - {"AssemblyVersion", `AssemblyVersion\s*\(\s*"([^"]+)"`}, - {"Version", `([^<]+)`}, - {"version", `"version"\s*:\s*"([^"]+)"`}, - }, - "php": { - {"version", `"version"\s*:\s*"([^"]+)"`}, - {"const VERSION", `const\s+VERSION\s*=\s*['"]([^'"]+)['"]`}, - }, - "ruby": { - {"VERSION", `VERSION\s*=\s*['"]([^'"]+)['"]`}, - {"version", `\.version\s*=\s*['"]([^'"]+)['"]`}, - }, -} - func (s *ReleaseService) detectPatternInFileForLanguage(filePath, lang string) (string, error) { content, err := os.ReadFile(filePath) if err != nil { @@ -1162,19 +1323,25 @@ func (s *ReleaseService) detectPatternInFileForLanguage(filePath, lang string) ( } fileContent := string(content) - patterns, ok := versionPatternsByLanguage[lang] + patterns, ok := consolidatedVersionPatterns[lang] if !ok { return s.detectPatternInFile(filePath) } for _, patternInfo := range patterns { - re, err := regexp.Compile(patternInfo.pattern) + re, err := regexp.Compile(patternInfo.detectionPattern) if err != nil { continue } if re.MatchString(fileContent) { - adjustedPattern := s.adjustPatternForReplacement(patternInfo.pattern, fileContent, lang) - return adjustedPattern, nil + matches := re.FindStringSubmatch(fileContent) + if len(matches) > 1 { + versionValue := matches[1] + if err := s.validateVersionString(versionValue); err != nil { + continue + } + } + return patternInfo.replacementPattern, nil } } diff --git a/internal/services/release_service_test.go b/internal/services/release_service_test.go index 596d606..5f2f746 100644 --- a/internal/services/release_service_test.go +++ b/internal/services/release_service_test.go @@ -1389,3 +1389,91 @@ Initial release mockGit.AssertExpectations(t) }) } + +func TestReleaseService_FindVersionFile_OptimizationAndValidation(t *testing.T) { + t.Run("Should ignore invalid semver versions", func(t *testing.T) { + dir := t.TempDir() + versionFile := filepath.Join(dir, "version.go") + content := `package main +const Version = "dev" +` + err := os.WriteFile(versionFile, []byte(content), 0644) + require.NoError(t, err) + + service := &ReleaseService{} + + cwd, _ := os.Getwd() + defer os.Chdir(cwd) + os.Chdir(dir) + + os.WriteFile("go.mod", []byte("module test"), 0644) + + foundFile, pattern, err := service.FindVersionFile(context.Background()) + assert.Error(t, err) + assert.Equal(t, "", foundFile) + assert.Equal(t, "", pattern) + }) + + t.Run("Should ignore node_modules", func(t *testing.T) { + dir := t.TempDir() + cwd, _ := os.Getwd() + defer os.Chdir(cwd) + os.Chdir(dir) + + os.WriteFile("package.json", []byte(`{"name":"test"}`), 0644) + + nodeModules := filepath.Join(dir, "node_modules") + os.Mkdir(nodeModules, 0755) + + ignoredFile := filepath.Join(nodeModules, "package.json") + os.WriteFile(ignoredFile, []byte(`{"version": "1.0.0"}`), 0644) + + service := &ReleaseService{} + foundFile, _, err := service.FindVersionFile(context.Background()) + + assert.Error(t, err) + assert.NotContains(t, foundFile, "node_modules") + }) + + t.Run("Should respect max recursion depth", func(t *testing.T) { + dir := t.TempDir() + cwd, _ := os.Getwd() + defer os.Chdir(cwd) + os.Chdir(dir) + os.WriteFile("go.mod", []byte("module test"), 0644) + + deepDir := filepath.Join(dir, "1", "2", "3", "4", "5", "6") + os.MkdirAll(deepDir, 0755) + + versionFile := filepath.Join(deepDir, "version.go") + os.WriteFile(versionFile, []byte(`package main +const Version = "1.0.0"`), 0644) + + service := &ReleaseService{} + foundFile, _, err := service.FindVersionFile(context.Background()) + + assert.Error(t, err) + assert.Equal(t, "", foundFile) + }) + + t.Run("Should find file within recursion depth", func(t *testing.T) { + dir := t.TempDir() + cwd, _ := os.Getwd() + defer os.Chdir(cwd) + os.Chdir(dir) + os.WriteFile("go.mod", []byte("module test"), 0644) + + shallowDir := filepath.Join(dir, "internal") + os.MkdirAll(shallowDir, 0755) + + versionFile := filepath.Join(shallowDir, "version.go") + os.WriteFile(versionFile, []byte(`package main +const Version = "1.0.0"`), 0644) + + service := &ReleaseService{} + foundFile, _, err := service.FindVersionFile(context.Background()) + + assert.NoError(t, err) + assert.Contains(t, foundFile, "version.go") + }) +}