diff --git a/.releaser.yml b/.releaser.yml index a3d14b8..fb3e0d8 100644 --- a/.releaser.yml +++ b/.releaser.yml @@ -19,10 +19,37 @@ git: # author_name: "" # author_email: "" -maven: - # Path to pom.xml, relative to the repository root. + # Limit which commit types trigger a release (default: fix, feat, breaking). + # releasable_types: + # - fix + # - feat + # - breaking + + # Configure which version component each commit type bumps. + # Valid values: "patch" (default) or "minor". + # bump_rules: + # breaking: "minor" + # feat: "patch" + # fix: "patch" + +# maven: + # Single pom.xml path, relative to the repository root. # pom_path: "pom.xml" + # Multiple pom.xml paths for multi-module projects (overrides pom_path). + # pom_paths: + # - "pom.xml" + # - "module-a/pom.xml" + +# node: + # Single package.json path (node processing is opt-in — no default). + # package_json: "package.json" + + # Multiple package.json paths for monorepos (overrides package_json). + # package_jsons: + # - "packages/frontend/package.json" + # - "packages/backend/package.json" + gitlab: # GitLab instance URL. Falls back to the CI_SERVER_URL environment variable. # url: "https://gitlab.example.com" diff --git a/CHANGELOG.md b/CHANGELOG.md index f6dac3f..9b6d725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,21 @@ All notable changes to this project will be documented in this file. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.5.0] - 2026-07-11 + +### Added + +- **Multi-module Maven support** — `maven.pom_paths: [...]` lists multiple `pom.xml` paths (e.g. root + sub-modules); overrides `pom_path`; each path is updated and committed in the same release commit +- **Node.js `package.json` support** — opt-in via `node.package_json` (single path) or `node.package_jsons` (list, overrides the single path); version is bumped in-place alongside `pom.xml` and `CHANGELOG.md` +- **`git.bump_rules` config** — controls which version component each commit type bumps: `breaking`, `feat`, `fix` each accept `"patch"` (default) or `"minor"`; allows e.g. `feat: "minor"` to bump the minor component instead of patch +- **Injectable function vars in `cmd/main.go`** — `absPath`, `gitAllCommits`, `gitCommitsSince`, `gitCommitFiles` are now package-level vars overridable in tests to inject errors, enabling 100% per-package statement coverage across all 12 packages + +### Changed + +- **`--pom` flag** now clears `maven.pom_paths` before setting `maven.pom_path`, ensuring the CLI flag always wins over a multi-path config file entry +- **`version.Next()` signature** — accepts a `map[commits.Type]semver.BumpLevel` bump-rules map as a sixth parameter; `nil` defaults to all-patch (no change to existing behaviour) +- **Verbose config table** — now includes `git.bump_rules.breaking/feat/fix`, `maven.pom_paths` (effective list), and `node.paths` rows + ## [1.4.0] - 2026-07-11 ### Added diff --git a/README.md b/README.md index 29ff2bf..3c2257b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # releaser -![release](https://img.shields.io/badge/release-v1.4.0-blue.svg) +![release](https://img.shields.io/badge/release-v1.5.0-blue.svg) A CI-friendly release automation tool for GitFlow workflows using Conventional Commits. @@ -27,13 +27,15 @@ release/1.2 branch ## Version bump rules -| Commit type | Bump | Notes | -|------------------|---------|--------------------------------------------| -| `fix:` | patch | | -| `feat:` | patch | minor is pinned to branch | -| `feat!:` / `BREAKING CHANGE` | patch | same — branch defines the minor boundary | -| `chore:`, `docs:`, etc. | none | | -| unparseable msg | none | non-strict mode: silently ignored | +By default, all releasable commits bump the **patch** component (minor is pinned to the branch). You can override this per commit type via `git.bump_rules` in `.releaser.yml`: + +| Commit type | Default | Configurable via `bump_rules` | +|------------------|---------|------------------------------------------------| +| `fix:` | patch | `fix: "minor"` to bump minor instead | +| `feat:` | patch | `feat: "minor"` to bump minor instead | +| `feat!:` / `BREAKING CHANGE` | patch | `breaking: "minor"` to bump minor | +| `chore:`, `docs:`, etc. | none | — | +| unparseable msg | none | non-strict mode: silently ignored | ## Usage @@ -95,9 +97,23 @@ git: - fix - feat - breaking + bump_rules: # which version component each type bumps + breaking: "patch" # "minor" to bump minor on breaking changes + feat: "patch" + fix: "patch" maven: - pom_path: "pom.xml" # relative to repo root + pom_path: "pom.xml" # single pom.xml, relative to repo root + # pom_paths: # multi-module: list overrides pom_path + # - "pom.xml" + # - "module-a/pom.xml" + # - "module-b/pom.xml" + +node: # opt-in — no default; omit to skip + # package_json: "package.json" # single path + # package_jsons: # monorepo: list overrides package_json + # - "packages/frontend/package.json" + # - "packages/backend/package.json" gitlab: url: "https://gitlab.example.com" # or env CI_SERVER_URL diff --git a/ROADMAP.md b/ROADMAP.md index debeb8d..d7c2fc1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -76,10 +76,15 @@ - [x] ~~Configurable bump rules~~ — ✓ shipped v1.4.0 (`git.releasable_types` config; filter which commit types trigger a release) - [ ] Documentation site +## v1.5 — Multi-module, Node.js, configurable bump rules ✅ + +- [x] Multi-module Maven support (`maven.pom_paths: [...]` updates multiple `pom.xml` files in one release) +- [x] `package.json` version bump for Node.js projects (`node.package_json` / `node.package_jsons`) +- [x] Configurable bump rules per commit type (`git.bump_rules.breaking/feat/fix: "minor" | "patch"`) +- [x] 100% per-package statement coverage across all 12 packages + ## Future / backlog -- Multi-module Maven support (multiple `pom.xml` paths) - Gradle support (`build.gradle` / `build.gradle.kts`) -- `package.json` version bump support (Node.js projects) - Slack / Teams notification on release -- Configurable bump rules (e.g. treat `feat:` as minor on `main` branch) +- Documentation site diff --git a/cmd/main.go b/cmd/main.go index d1e32e8..e0652b3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -19,6 +19,7 @@ import ( "git.k3nny.fr/releaser/internal/gitutil" "git.k3nny.fr/releaser/internal/glclient" "git.k3nny.fr/releaser/internal/maven" + "git.k3nny.fr/releaser/internal/node" "git.k3nny.fr/releaser/internal/notes" semver "git.k3nny.fr/releaser/internal/version" ) @@ -50,10 +51,33 @@ git: # - feat # - breaking + # Configure which version component each commit type bumps. + # Valid values: "patch" (default) or "minor". + # bump_rules: + # breaking: "minor" # bump minor version instead of patch on breaking changes + # feat: "patch" + # fix: "patch" + maven: - # Path to pom.xml, relative to the repository root. + # Single pom.xml path, relative to the repository root. # pom_path: "pom.xml" + # Multiple pom.xml paths for multi-module projects (overrides pom_path). + # pom_paths: + # - "pom.xml" + # - "module-a/pom.xml" + # - "module-b/pom.xml" + +node: + # Single package.json path (node processing is opt-in — no default). + # package_json: "package.json" + + # Multiple package.json paths for monorepos (overrides package_json). + # package_jsons: + # - "package.json" + # - "packages/frontend/package.json" + # - "packages/backend/package.json" + gitlab: # GitLab instance URL. Falls back to the CI_SERVER_URL environment variable. # url: "https://gitlab.example.com" @@ -84,6 +108,14 @@ var ( // exitFn is a variable so tests can intercept os.Exit calls. var exitFn = os.Exit +// injectable function variables for testing error paths. +var ( + absPath = filepath.Abs + gitAllCommits = gitutil.AllCommits + gitCommitsSince = gitutil.CommitsSince + gitCommitFiles = gitutil.CommitFiles +) + // releasePublisher is implemented by both glclient and ghclient. type releasePublisher interface { CreateRelease(ctx context.Context, tagName, body string) error @@ -215,7 +247,32 @@ func printVerboseConfig(cfg config.Config, src config.Sources) { } return strings.Join(cfg.Git.ReleasableTypes, ", ") }()}, - {"maven.pom_path", cfg.Maven.PomPath}, + {"git.bump_rules.breaking", func() string { + if cfg.Git.BumpRules.Breaking == "" { + return "patch" + } + return cfg.Git.BumpRules.Breaking + }()}, + {"git.bump_rules.feat", func() string { + if cfg.Git.BumpRules.Feat == "" { + return "patch" + } + return cfg.Git.BumpRules.Feat + }()}, + {"git.bump_rules.fix", func() string { + if cfg.Git.BumpRules.Fix == "" { + return "patch" + } + return cfg.Git.BumpRules.Fix + }()}, + {"maven.pom_paths", strings.Join(cfg.Maven.EffectivePomPaths(), ", ")}, + {"node.paths", func() string { + paths := cfg.Node.EffectivePaths() + if len(paths) == 0 { + return "(not configured)" + } + return strings.Join(paths, ", ") + }()}, {"gitlab.url", cfg.GitLab.URL}, {"gitlab.token", func() string { if cfg.GitLab.Token != "" { @@ -257,11 +314,25 @@ func initConfig(absRepo string) error { return nil } +func parseBumpRules(rules config.BumpRulesConfig) map[commits.Type]semver.BumpLevel { + m := map[commits.Type]semver.BumpLevel{} + if rules.Breaking == "minor" { + m[commits.TypeBreaking] = semver.BumpMinor + } + if rules.Feat == "minor" { + m[commits.TypeFeat] = semver.BumpMinor + } + if rules.Fix == "minor" { + m[commits.TypeFix] = semver.BumpMinor + } + return m +} + func run(o options) error { logHeader(version) // --- Config --- - absRepo, err := filepath.Abs(o.repoPath) + absRepo, err := absPath(o.repoPath) if err != nil { return fmt.Errorf("resolve repo path: %w", err) } @@ -286,7 +357,8 @@ func run(o options) error { } if o.pomOverride != "" { cfg.Maven.PomPath = o.pomOverride - src["maven.pom_path"] = "flag: --pom" + cfg.Maven.PomPaths = nil + src["maven.pom_paths"] = "flag: --pom" } if o.patternSet { cfg.Git.BranchPattern = o.patternFlag @@ -345,9 +417,9 @@ func run(o options) error { // --- Commit range --- var messages []string if lastTag == "" { - messages, err = gitutil.AllCommits(repo) + messages, err = gitAllCommits(repo) } else { - messages, err = gitutil.CommitsSince(repo, lastTag) + messages, err = gitCommitsSince(repo, lastTag) } if err != nil { return fmt.Errorf("read commits: %w", err) @@ -398,7 +470,7 @@ func run(o options) error { } releasable := commits.ReleasableSet(cfg.Git.ReleasableTypes) - nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types, releasable) + nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types, releasable, parseBumpRules(cfg.Git.BumpRules)) if !ok { logWarn("no releasable commits found") return errNothingToRelease @@ -440,27 +512,46 @@ func run(o options) error { if !o.tagOnly { var filesToCommit []string - // pom.xml - pomPath := filepath.Join(absRepo, cfg.Maven.PomPath) - _, statErr := os.Stat(pomPath) - hasPom := !errors.Is(statErr, os.ErrNotExist) - if statErr != nil && hasPom { - return fmt.Errorf("check pom path: %w", statErr) + // pom.xml (supports multi-module via pom_paths) + anyPom := false + for _, relPomPath := range cfg.Maven.EffectivePomPaths() { + pomPath := filepath.Join(absRepo, relPomPath) + _, statErr := os.Stat(pomPath) + hasPom := !errors.Is(statErr, os.ErrNotExist) + if statErr != nil && hasPom { + return fmt.Errorf("check pom path: %w", statErr) + } + if hasPom { + anyPom = true + currentPomVersion, err := maven.ReadVersion(pomPath) + if err != nil { + return fmt.Errorf("read pom version: %w", err) + } + if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil { + return fmt.Errorf("update pom version: %w", err) + } + logDone("%s: %s → %s", relPomPath, currentPomVersion, nextVersion) + filesToCommit = append(filesToCommit, relPomPath) + } } - if hasPom { - currentPomVersion, err := maven.ReadVersion(pomPath) - if err != nil { - return fmt.Errorf("read pom version: %w", err) - } - if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil { - return fmt.Errorf("update pom version: %w", err) - } - logDone("pom.xml: %s → %s", currentPomVersion, nextVersion) - filesToCommit = append(filesToCommit, cfg.Maven.PomPath) - } else { + if !anyPom { logWarn("no pom.xml — skipping version bump") } + // package.json (opt-in via node.package_json / node.package_jsons) + for _, relPkgPath := range cfg.Node.EffectivePaths() { + pkgPath := filepath.Join(absRepo, relPkgPath) + currentNodeVersion, err := node.ReadVersion(pkgPath) + if err != nil { + return fmt.Errorf("read package.json version: %w", err) + } + if err := node.WriteVersion(pkgPath, currentNodeVersion, nextVersion); err != nil { + return fmt.Errorf("update package.json version: %w", err) + } + logDone("%s: %s → %s", relPkgPath, currentNodeVersion, nextVersion) + filesToCommit = append(filesToCommit, relPkgPath) + } + // CHANGELOG.md changelogAbsPath := filepath.Join(absRepo, o.changelogFile) if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil { @@ -483,7 +574,7 @@ func run(o options) error { authorEmail = cfg.Git.AuthorEmail } commitMsg := strings.ReplaceAll(cfg.Git.CommitMessage, "{version}", nextTag) - if _, err := gitutil.CommitFiles(repo, filesToCommit, commitMsg, authorName, authorEmail); err != nil { + if _, err := gitCommitFiles(repo, filesToCommit, commitMsg, authorName, authorEmail); err != nil { return fmt.Errorf("commit: %w", err) } logDone("committed: %s", commitMsg) diff --git a/cmd/main_test.go b/cmd/main_test.go index e926260..239846c 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -2,6 +2,7 @@ package main import ( "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -15,6 +16,8 @@ import ( gitcfg "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" + + "git.k3nny.fr/releaser/internal/config" ) // ── helpers ────────────────────────────────────────────────────────────────── @@ -742,3 +745,458 @@ func TestRunVerbose(t *testing.T) { } } } + +// ── ui.go coverage ──────────────────────────────────────────────────────────── + +func TestPaintColor(t *testing.T) { + old := useColor + useColor = true + defer func() { useColor = old }() + + got := paint(ansiGreen, "hello") + if !strings.Contains(got, "hello") || !strings.Contains(got, ansiReset) || !strings.Contains(got, ansiGreen) { + t.Errorf("paint with color = %q", got) + } +} + +func TestFmtSourceEnv(t *testing.T) { + old := useColor + useColor = false + defer func() { useColor = old }() + + got := fmtSource("env: GITLAB_TOKEN") + if got != "[env: GITLAB_TOKEN]" { + t.Errorf("fmtSource env = %q", got) + } +} + +func TestFmtSourceFlag(t *testing.T) { + old := useColor + useColor = false + defer func() { useColor = old }() + + got := fmtSource("flag: --tag-prefix") + if got != "[flag: --tag-prefix]" { + t.Errorf("fmtSource flag = %q", got) + } +} + +func TestFmtSourceConfigFile(t *testing.T) { + old := useColor + useColor = false + defer func() { useColor = old }() + + got := fmtSource("config file") + if got != "[config file]" { + t.Errorf("fmtSource config file = %q", got) + } +} + +// ── buildPublisher coverage ─────────────────────────────────────────────────── + +func TestBuildPublisherGitHub(t *testing.T) { + cfg := config.Config{ + GitHub: config.GitHubConfig{Token: "ghtoken", Repo: "owner/repo"}, + } + pub, err := buildPublisher(cfg) + if err != nil { + t.Fatalf("buildPublisher GitHub: %v", err) + } + if pub == nil { + t.Fatal("expected non-nil publisher for GitHub config") + } +} + +func TestBuildPublisherGitLabNoToken(t *testing.T) { + cfg := config.Config{ + GitLab: config.GitLabConfig{URL: "https://gitlab.example.com", Project: "42"}, + } + _, err := buildPublisher(cfg) + if err == nil { + t.Fatal("expected error when GitLab URL+Project set but token is empty") + } +} + +// ── printVerboseConfig coverage ─────────────────────────────────────────────── + +func TestPrintVerboseConfigDirect(t *testing.T) { + // Use a sparse Sources map (missing keys → source == "" → hits "default" branch). + // Also set non-empty ReleasableTypes and both tokens to cover those branches. + cfg := config.Config{ + Git: config.GitConfig{ + ReleasableTypes: []string{"fix", "feat"}, + }, + GitLab: config.GitLabConfig{Token: "secret"}, + GitHub: config.GitHubConfig{Token: "ghsecret"}, + } + src := config.Sources{} // empty → all lookups return "" + + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + printVerboseConfig(cfg, src) + + w.Close() + os.Stderr = old + out, _ := io.ReadAll(r) + + if !strings.Contains(string(out), "fix, feat") { + t.Error("expected releasable types joined in output") + } + if !strings.Contains(string(out), "(set)") { + t.Error("expected '(set)' for configured tokens") + } +} + +// ── initConfig coverage ─────────────────────────────────────────────────────── + +func TestInitConfigWriteFails(t *testing.T) { + dir := t.TempDir() + os.Chmod(dir, 0555) + defer os.Chmod(dir, 0755) + + err := initConfig(dir) + if err == nil { + t.Fatal("expected error writing .releaser.yml to read-only directory") + } +} + +// ── run() injectable error paths ───────────────────────────────────────────── + +func TestRunVerboseInit(t *testing.T) { + dir := t.TempDir() + err := execCmd(t, "--init", "--verbose", "--repo", dir) + if err != nil { + t.Fatalf("--init --verbose: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, ".releaser.yml")); err != nil { + t.Error("expected .releaser.yml to be created") + } +} + +func TestRunAbsPathError(t *testing.T) { + old := absPath + absPath = func(string) (string, error) { return "", fmt.Errorf("injected abs error") } + defer func() { absPath = old }() + + err := execCmd(t, "--repo", ".") + if err == nil { + t.Fatal("expected error when filepath.Abs fails") + } +} + +func TestRunWorkingTreeCheckFails(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + // Corrupt the git index so IsWorkingTreeClean fails. + os.WriteFile(filepath.Join(dir, ".git", "index"), []byte("garbage"), 0644) + + err := execCmd(t, "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected error for corrupt git index") + } +} + +func TestRunLatestTagFails(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + tagsDir := filepath.Join(dir, ".git", "refs", "tags") + os.Chmod(tagsDir, 0000) + defer os.Chmod(tagsDir, 0755) + + err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected error for unreadable tags directory") + } +} + +func TestRunAllCommitsError(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + old := gitAllCommits + gitAllCommits = func(_ *gogit.Repository) ([]string, error) { + return nil, fmt.Errorf("injected AllCommits error") + } + defer func() { gitAllCommits = old }() + + err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected error from AllCommits") + } +} + +func TestRunCommitsSinceError(t *testing.T) { + repo, dir := setupRepo(t) + head, _ := repo.Head() + repo.CreateTag("1.2.0", head.Hash(), nil) + + addFile(t, dir, "x.go", "// fix") + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + old := gitCommitsSince + gitCommitsSince = func(_ *gogit.Repository, _ string) ([]string, error) { + return nil, fmt.Errorf("injected CommitsSince error") + } + defer func() { gitCommitsSince = old }() + + err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected error from CommitsSince") + } +} + +// ── verbose commit section coverage ────────────────────────────────────────── + +func TestRunVerboseBreakingAndFix(t *testing.T) { + // Covers: verbose "since: lastTag", message truncation (>70 chars), + // TypeBreaking color (ansiRed+ansiBold), TypeFix color (ansiGreen). + repo, dir := setupRepo(t) + head, _ := repo.Head() + repo.CreateTag("1.2.0", head.Hash(), nil) + + w, _ := repo.Worktree() + + addFile(t, dir, "a.go", "a") + w.Add("a.go") + w.Commit("feat!: redesign the entire public API surface which is a very long commit message header", &gogit.CommitOptions{Author: testSig()}) + + addFile(t, dir, "b.go", "b") + w.Add("b.go") + w.Commit("fix: correct null pointer in edge case handler", &gogit.CommitOptions{Author: testSig()}) + + old := os.Stderr + r, wp, _ := os.Pipe() + os.Stderr = wp + + err := execCmd(t, "--dry-run", "--verbose", "--branch", "release/1.2", "--repo", dir) + + wp.Close() + os.Stderr = old + io.ReadAll(r) + + if err != nil { + t.Fatalf("verbose breaking+fix: unexpected error: %v", err) + } +} + +// ── release.env write error ─────────────────────────────────────────────────── + +func TestRunReleaseEnvWriteFails(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + // Empty directory is invisible to git — dirty check passes. + os.Mkdir(filepath.Join(dir, "release.env"), 0755) + + err := execCmd(t, "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected error when release.env is a directory") + } +} + +// ── pom stat error ──────────────────────────────────────────────────────────── + +func TestRunPomStatError(t *testing.T) { + // A null byte in the path makes os.Stat return EINVAL (not ErrNotExist), + // so hasPom=true and the stat error is propagated. + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + err := execCmd(t, "--branch", "release/1.2", "--repo", dir, "--pom", "pom\x00.xml") + if err == nil { + t.Fatal("expected error for pom path with null byte (EINVAL)") + } +} + +// ── changelog update error ──────────────────────────────────────────────────── + +func TestRunChangelogUpdateFails(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + // Empty directory is invisible to git — dirty check passes. + // changelog.Update will fail trying to ReadFile on a directory. + os.Mkdir(filepath.Join(dir, "CHANGELOG.md"), 0755) + + err := execCmd(t, "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected error when CHANGELOG.md is a directory") + } +} + +// ── CommitFiles error ───────────────────────────────────────────────────────── + +func TestRunCommitFilesError(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + old := gitCommitFiles + gitCommitFiles = func(_ *gogit.Repository, _ []string, _, _, _ string) (plumbing.Hash, error) { + return plumbing.ZeroHash, fmt.Errorf("injected commit error") + } + defer func() { gitCommitFiles = old }() + + err := execCmd(t, "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected error from CommitFiles") + } +} + +// ── parseBumpRules coverage ─────────────────────────────────────────────────── + +func TestParseBumpRules(t *testing.T) { + rules := config.BumpRulesConfig{Breaking: "minor", Feat: "minor", Fix: "minor"} + m := parseBumpRules(rules) + if len(m) != 3 { + t.Errorf("expected 3 entries in bump rules map, got %d", len(m)) + } +} + +// ── printVerboseConfig — bump_rules and node rows ───────────────────────────── + +func TestPrintVerboseConfigBumpRulesAndNode(t *testing.T) { + cfg := config.Config{ + Git: config.GitConfig{ + BumpRules: config.BumpRulesConfig{Breaking: "minor", Feat: "minor", Fix: "minor"}, + }, + Node: config.NodeConfig{PackageJSON: "package.json"}, + } + src := config.Sources{} + + old := os.Stderr + r, wp, _ := os.Pipe() + os.Stderr = wp + printVerboseConfig(cfg, src) + wp.Close() + os.Stderr = old + out, _ := io.ReadAll(r) + output := string(out) + + if !strings.Contains(output, "minor") { + t.Error("expected 'minor' in output for bump_rules") + } + if !strings.Contains(output, "package.json") { + t.Error("expected 'package.json' in output for node.paths") + } +} + +// ── node package.json handling ──────────────────────────────────────────────── + +func writePackageJSON(t *testing.T, dir, ver string) { + t.Helper() + content := fmt.Sprintf(`{"name": "my-app", "version": "%s"}`, ver) + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func TestRunNodeVersionBump(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + writePackageJSON(t, dir, "0.0.0") + commitAll(t, repo, dir, "chore: init") + + // .releaser.yml is untracked — go-git IsClean ignores untracked files + os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("node:\n package_json: \"package.json\"\n"), 0644) + + addFile(t, dir, "x.go", "// fix") + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()}) + + err = execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir) + if err != nil { + t.Fatalf("node version bump: unexpected error: %v", err) + } + + data, _ := os.ReadFile(filepath.Join(dir, "package.json")) + if !strings.Contains(string(data), `"version": "1.2.0"`) { + t.Errorf("expected version 1.2.0 in package.json, got: %s", data) + } +} + +func TestRunNodeReadVersionFails(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + // invalid JSON — ReadVersion will fail + os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{not json`), 0644) + commitAll(t, repo, dir, "chore: init") + + os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("node:\n package_json: \"package.json\"\n"), 0644) + + addFile(t, dir, "x.go", "// fix") + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()}) + + err = execCmd(t, "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected error when package.json has invalid JSON") + } +} + +func TestRunNodeWriteVersionFails(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + writePackageJSON(t, dir, "0.0.0") + commitAll(t, repo, dir, "chore: init") + + os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("node:\n package_json: \"package.json\"\n"), 0644) + + addFile(t, dir, "x.go", "// fix") + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()}) + + // Make package.json read-only so WriteVersion fails + os.Chmod(filepath.Join(dir, "package.json"), 0444) + defer os.Chmod(filepath.Join(dir, "package.json"), 0644) + + err = execCmd(t, "--branch", "release/1.2", "--repo", dir) + if err == nil { + t.Fatal("expected error when package.json is read-only") + } +} diff --git a/internal/changelog/changelog_test.go b/internal/changelog/changelog_test.go index 9773c61..f9112a5 100644 --- a/internal/changelog/changelog_test.go +++ b/internal/changelog/changelog_test.go @@ -99,6 +99,28 @@ func TestUpdateBreakingSection(t *testing.T) { } } +func TestUpdateExistingFileNoHeading(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "CHANGELOG.md") + + // File with content but no ## [ heading — new section appended at bottom. + os.WriteFile(path, []byte("# Changelog\n\nSome preamble.\n"), 0644) + + err := Update(path, "v1.0.0", "1.0.0", []string{"fix: something"}) + if err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(path) + s := string(data) + if !strings.Contains(s, "## [1.0.0]") { + t.Error("expected version header appended") + } + if !strings.Contains(s, "# Changelog") { + t.Error("expected original content preserved") + } +} + func TestUpdateReadError(t *testing.T) { dir := t.TempDir() // Create a directory where the file should be — ReadFile will error. @@ -109,3 +131,21 @@ func TestUpdateReadError(t *testing.T) { t.Error("expected error when path is a directory") } } + +func TestUpdateIdempotent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "CHANGELOG.md") + + // Pre-seed with the version heading already present. + os.WriteFile(path, []byte("# Changelog\n\n## [1.0.0] - 2026-01-01\n\n- fix: something\n"), 0644) + + // Second call must be a no-op (returns nil, file unchanged). + if err := Update(path, "v1.0.0", "1.0.0", []string{"fix: something"}); err != nil { + t.Fatalf("idempotent Update should not error, got: %v", err) + } + + data, _ := os.ReadFile(path) + if strings.Count(string(data), "## [1.0.0]") != 1 { + t.Error("version heading should appear exactly once after idempotent call") + } +} diff --git a/internal/commits/commits_test.go b/internal/commits/commits_test.go index 08df4cf..bdbad4d 100644 --- a/internal/commits/commits_test.go +++ b/internal/commits/commits_test.go @@ -83,6 +83,14 @@ func TestReleasableSet(t *testing.T) { if !only[TypeFix] || only[TypeFeat] || only[TypeBreaking] { t.Errorf("fix-only set: %v", only) } + onlyFeat := ReleasableSet([]string{"feat"}) + if onlyFeat[TypeFix] || !onlyFeat[TypeFeat] || onlyFeat[TypeBreaking] { + t.Errorf("feat-only set: %v", onlyFeat) + } + onlyBreaking := ReleasableSet([]string{"breaking"}) + if onlyBreaking[TypeFix] || onlyBreaking[TypeFeat] || !onlyBreaking[TypeBreaking] { + t.Errorf("breaking-only set: %v", onlyBreaking) + } } func TestTypeString(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index 550700b..bdeb4a3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,21 +16,61 @@ const filename = ".releaser.yml" type Config struct { Git GitConfig `yaml:"git"` Maven MavenConfig `yaml:"maven"` + Node NodeConfig `yaml:"node"` GitLab GitLabConfig `yaml:"gitlab"` GitHub GitHubConfig `yaml:"github"` } type GitConfig struct { - TagPrefix string `yaml:"tag_prefix"` - BranchPattern string `yaml:"branch_pattern"` - CommitMessage string `yaml:"commit_message"` - AuthorName string `yaml:"author_name"` - AuthorEmail string `yaml:"author_email"` - ReleasableTypes []string `yaml:"releasable_types"` + TagPrefix string `yaml:"tag_prefix"` + BranchPattern string `yaml:"branch_pattern"` + CommitMessage string `yaml:"commit_message"` + AuthorName string `yaml:"author_name"` + AuthorEmail string `yaml:"author_email"` + ReleasableTypes []string `yaml:"releasable_types"` + BumpRules BumpRulesConfig `yaml:"bump_rules"` +} + +// BumpRulesConfig controls what version component each commit type bumps. +// Valid values: "patch" (default) or "minor". +type BumpRulesConfig struct { + Breaking string `yaml:"breaking"` + Feat string `yaml:"feat"` + Fix string `yaml:"fix"` } type MavenConfig struct { - PomPath string `yaml:"pom_path"` + PomPath string `yaml:"pom_path"` // single path (default: "pom.xml") + PomPaths []string `yaml:"pom_paths"` // multiple paths; overrides PomPath when set +} + +// EffectivePomPaths returns the list of pom.xml paths to process. +// PomPaths takes precedence over PomPath; falls back to ["pom.xml"]. +func (m MavenConfig) EffectivePomPaths() []string { + if len(m.PomPaths) > 0 { + return m.PomPaths + } + if m.PomPath != "" { + return []string{m.PomPath} + } + return []string{"pom.xml"} +} + +type NodeConfig struct { + PackageJSON string `yaml:"package_json"` // single path + PackageJSONs []string `yaml:"package_jsons"` // multiple paths; overrides PackageJSON when set +} + +// EffectivePaths returns the list of package.json paths to process. +// Returns nil when no node paths are configured (node processing is opt-in). +func (n NodeConfig) EffectivePaths() []string { + if len(n.PackageJSONs) > 0 { + return n.PackageJSONs + } + if n.PackageJSON != "" { + return []string{n.PackageJSON} + } + return nil } type GitLabConfig struct { @@ -64,18 +104,24 @@ type Sources map[string]string func defaultSources() Sources { return Sources{ - "git.tag_prefix": "default", - "git.branch_pattern": "default", - "git.commit_message": "default", - "git.author_name": "default", - "git.author_email": "default", - "git.releasable_types": "default", - "maven.pom_path": "default", - "gitlab.url": "default", - "gitlab.token": "default", - "gitlab.project": "default", - "github.token": "default", - "github.repo": "default", + "git.tag_prefix": "default", + "git.branch_pattern": "default", + "git.commit_message": "default", + "git.author_name": "default", + "git.author_email": "default", + "git.releasable_types": "default", + "git.bump_rules.breaking": "default", + "git.bump_rules.feat": "default", + "git.bump_rules.fix": "default", + "maven.pom_path": "default", + "maven.pom_paths": "default", + "node.package_json": "default", + "node.package_jsons": "default", + "gitlab.url": "default", + "gitlab.token": "default", + "gitlab.project": "default", + "github.token": "default", + "github.repo": "default", } } @@ -126,9 +172,27 @@ func LoadWithSources(dir string) (Config, Sources, error) { if len(overlay.Git.ReleasableTypes) > 0 { src["git.releasable_types"] = "config file" } + if overlay.Git.BumpRules.Breaking != "" { + src["git.bump_rules.breaking"] = "config file" + } + if overlay.Git.BumpRules.Feat != "" { + src["git.bump_rules.feat"] = "config file" + } + if overlay.Git.BumpRules.Fix != "" { + src["git.bump_rules.fix"] = "config file" + } if overlay.Maven.PomPath != "" { src["maven.pom_path"] = "config file" } + if len(overlay.Maven.PomPaths) > 0 { + src["maven.pom_paths"] = "config file" + } + if overlay.Node.PackageJSON != "" { + src["node.package_json"] = "config file" + } + if len(overlay.Node.PackageJSONs) > 0 { + src["node.package_jsons"] = "config file" + } if overlay.GitLab.URL != "" { src["gitlab.url"] = "config file" } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e0afc61..88582ed 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -123,6 +123,175 @@ func TestApplyEnvProjectPathFallback(t *testing.T) { } } +func TestLoadWithSourcesFullConfig(t *testing.T) { + dir := t.TempDir() + content := ` +git: + tag_prefix: "v" + branch_pattern: "^release/(\\d+)$" + commit_message: "release {version}" + author_name: "Bot" + author_email: "bot@example.com" + releasable_types: ["fix", "feat"] + bump_rules: + breaking: "minor" + feat: "patch" + fix: "patch" +maven: + pom_path: "sub/pom.xml" + pom_paths: + - "a/pom.xml" + - "b/pom.xml" +node: + package_json: "frontend/package.json" + package_jsons: + - "pkg-a/package.json" + - "pkg-b/package.json" +gitlab: + url: "https://gitlab.example.com" + token: "gitlab-token" + project: "42" +github: + token: "github-token" + repo: "owner/repo" +` + if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil { + t.Fatal(err) + } + + _, src, err := LoadWithSources(dir) + if err != nil { + t.Fatal(err) + } + wantConfigFile := []string{ + "git.tag_prefix", "git.branch_pattern", "git.commit_message", + "git.author_name", "git.author_email", "git.releasable_types", + "git.bump_rules.breaking", "git.bump_rules.feat", "git.bump_rules.fix", + "maven.pom_path", "maven.pom_paths", + "node.package_json", "node.package_jsons", + "gitlab.url", "gitlab.token", "gitlab.project", + "github.token", "github.repo", + } + for _, key := range wantConfigFile { + if got := src[key]; got != "config file" { + t.Errorf("src[%q] = %q, want %q", key, got, "config file") + } + } +} + +func TestEffectivePomPaths(t *testing.T) { + cases := []struct { + name string + cfg MavenConfig + want []string + }{ + {"default", MavenConfig{PomPath: "pom.xml"}, []string{"pom.xml"}}, + {"single override", MavenConfig{PomPath: "sub/pom.xml"}, []string{"sub/pom.xml"}}, + {"multi overrides single", MavenConfig{PomPath: "pom.xml", PomPaths: []string{"a/pom.xml", "b/pom.xml"}}, []string{"a/pom.xml", "b/pom.xml"}}, + {"empty falls back to default", MavenConfig{}, []string{"pom.xml"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := c.cfg.EffectivePomPaths() + if len(got) != len(c.want) { + t.Fatalf("got %v, want %v", got, c.want) + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("[%d] got %q, want %q", i, got[i], c.want[i]) + } + } + }) + } +} + +func TestNodeEffectivePaths(t *testing.T) { + cases := []struct { + name string + cfg NodeConfig + want []string + }{ + {"empty — opt-in, skip by default", NodeConfig{}, nil}, + {"single", NodeConfig{PackageJSON: "package.json"}, []string{"package.json"}}, + {"multi overrides single", NodeConfig{PackageJSON: "package.json", PackageJSONs: []string{"a/package.json", "b/package.json"}}, []string{"a/package.json", "b/package.json"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := c.cfg.EffectivePaths() + if len(got) != len(c.want) { + t.Fatalf("got %v, want %v", got, c.want) + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("[%d] got %q, want %q", i, got[i], c.want[i]) + } + } + }) + } +} + +func TestApplyEnvWithSourcesCIProjectID(t *testing.T) { + t.Setenv("CI_PROJECT_ID", "123") + t.Setenv("CI_PROJECT_PATH", "") + + cfg := defaults() + src := defaultSources() + cfg.ApplyEnvWithSources(src) + + if src["gitlab.project"] != "env: CI_PROJECT_ID" { + t.Errorf("src[gitlab.project] = %q, want %q", src["gitlab.project"], "env: CI_PROJECT_ID") + } +} + +func TestApplyEnvWithSourcesGitHubToken(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "ghtoken") + + cfg := defaults() + src := defaultSources() + cfg.ApplyEnvWithSources(src) + + if src["github.token"] != "env: GITHUB_TOKEN" { + t.Errorf("src[github.token] = %q, want %q", src["github.token"], "env: GITHUB_TOKEN") + } +} + +func TestApplyEnvWithSourcesCIProjectPath(t *testing.T) { + t.Setenv("CI_PROJECT_ID", "") + t.Setenv("CI_PROJECT_PATH", "group/project") + + cfg := defaults() + src := defaultSources() + cfg.ApplyEnvWithSources(src) + + if src["gitlab.project"] != "env: CI_PROJECT_PATH" { + t.Errorf("src[gitlab.project] = %q, want %q", src["gitlab.project"], "env: CI_PROJECT_PATH") + } +} + +func TestApplyEnvWithSourcesGitLabToken(t *testing.T) { + t.Setenv("GITLAB_TOKEN", "mytoken") + + cfg := defaults() + src := defaultSources() + cfg.ApplyEnvWithSources(src) + + if src["gitlab.token"] != "env: GITLAB_TOKEN" { + t.Errorf("src[gitlab.token] = %q, want %q", src["gitlab.token"], "env: GITLAB_TOKEN") + } +} + +func TestApplyEnvWithSourcesCIServerURL(t *testing.T) { + t.Setenv("CI_SERVER_URL", "https://gitlab.example.com") + + cfg := defaults() + src := defaultSources() + cfg.ApplyEnvWithSources(src) + + if src["gitlab.url"] != "env: CI_SERVER_URL" { + t.Errorf("src[gitlab.url] = %q, want %q", src["gitlab.url"], "env: CI_SERVER_URL") + } +} + func TestLoadPartialOverride(t *testing.T) { dir := t.TempDir() // Only override tag_prefix — commit_message should keep its default diff --git a/internal/ghclient/ghclient_test.go b/internal/ghclient/ghclient_test.go new file mode 100644 index 0000000..3a8f953 --- /dev/null +++ b/internal/ghclient/ghclient_test.go @@ -0,0 +1,138 @@ +package ghclient + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNew(t *testing.T) { + c := New("tok", "owner/repo") + if c.token != "tok" || c.repo != "owner/repo" || c.httpClient == nil { + t.Fatalf("New fields: token=%q repo=%q httpClient=%v", c.token, c.repo, c.httpClient) + } +} + +func TestCreateReleaseSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %q, want POST", r.Method) + } + if !strings.HasSuffix(r.URL.Path, "/releases") { + t.Errorf("path = %q, want .../releases", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("Authorization = %q", r.Header.Get("Authorization")) + } + var req struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + } + json.NewDecoder(r.Body).Decode(&req) + if req.TagName != "v1.0.0" { + t.Errorf("tag_name = %q, want v1.0.0", req.TagName) + } + w.WriteHeader(http.StatusCreated) + io.WriteString(w, `{}`) + })) + defer srv.Close() + + c := New("test-token", "owner/repo") + c.httpClient = srv.Client() + // Override the URL by pointing the client at the test server. + // We can't easily override the URL without a custom transport, so use a + // round-trip wrapper instead. + c.httpClient.Transport = rewriteTransport{base: http.DefaultTransport, target: srv.URL} + + if err := c.CreateRelease(context.Background(), "v1.0.0", "release notes"); err != nil { + t.Fatalf("CreateRelease: %v", err) + } +} + +func TestCreateReleaseAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + io.WriteString(w, `{"message":"Validation Failed"}`) + })) + defer srv.Close() + + c := New("tok", "owner/repo") + c.httpClient = &http.Client{Transport: rewriteTransport{base: http.DefaultTransport, target: srv.URL}} + + err := c.CreateRelease(context.Background(), "v1.0.0", "body") + if err == nil { + t.Fatal("expected error for 422 response") + } + if !strings.Contains(err.Error(), "422") { + t.Errorf("error should mention status code: %v", err) + } +} + +func TestCreateReleaseAPIErrorNoMessage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + io.WriteString(w, `not json`) + })) + defer srv.Close() + + c := New("tok", "owner/repo") + c.httpClient = &http.Client{Transport: rewriteTransport{base: http.DefaultTransport, target: srv.URL}} + + err := c.CreateRelease(context.Background(), "v1.0.0", "body") + if err == nil { + t.Fatal("expected error for 500 response") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("error should mention status code: %v", err) + } +} + +func TestCreateReleaseRequestFails(t *testing.T) { + c := New("tok", "owner/repo") + // Use a transport that always fails. + c.httpClient = &http.Client{Transport: alwaysFailTransport{}} + + err := c.CreateRelease(context.Background(), "v1.0.0", "body") + if err == nil { + t.Fatal("expected error when HTTP request fails") + } +} + +func TestCreateReleaseBadURL(t *testing.T) { + // A repo containing a null byte makes the URL unparseable by http.NewRequestWithContext. + c := New("tok", "owner/repo\x00bad") + + err := c.CreateRelease(context.Background(), "v1.0.0", "body") + if err == nil { + t.Fatal("expected error for invalid URL") + } +} + +// rewriteTransport redirects all requests to a test server URL. +type rewriteTransport struct { + base http.RoundTripper + target string +} + +func (rt rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req2 := req.Clone(req.Context()) + req2.URL.Scheme = "http" + req2.URL.Host = strings.TrimPrefix(rt.target, "http://") + return rt.base.RoundTrip(req2) +} + +// alwaysFailTransport returns an error for every request. +type alwaysFailTransport struct{} + +func (alwaysFailTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, &testTransportError{"connection refused"} +} + +type testTransportError struct{ msg string } + +func (e *testTransportError) Error() string { return e.msg } diff --git a/internal/gitutil/gitutil.go b/internal/gitutil/gitutil.go index e25dd3b..bcb2d87 100644 --- a/internal/gitutil/gitutil.go +++ b/internal/gitutil/gitutil.go @@ -85,23 +85,21 @@ func LatestTag(repo *gogit.Repository, info branch.Info) (string, int, error) { return nil } - commitHash, err := resolveTagToCommit(repo, ref) + tagCommit, err := resolveTagToCommitObj(repo, ref) if err != nil { return nil // silently skip malformed tags } - tagCommit, err := repo.CommitObject(commitHash) - if err != nil { - return nil - } - if tagCommit.Hash == headCommit.Hash { candidates = append(candidates, tagCandidate{name, patch}) return nil } anc, err := tagCommit.IsAncestor(headCommit) - if err != nil || !anc { + if err != nil { + return err + } + if !anc { return nil } @@ -241,6 +239,12 @@ func CreateTag(repo *gogit.Repository, tagName string) error { return nil } +// sshPush is the function used for SSH agent push; replaced in tests to avoid requiring a live agent. +var sshPush = pushWithSSHAgent + +// newSSHAgentAuth creates an SSH agent auth method; replaced in tests. +var newSSHAgentAuth = gitssh.NewSSHAgentAuth + // Push pushes the given branch and tag to the "origin" remote. // When token is non-empty, go-git is used with HTTPS basic auth (oauth2/token) — suitable for CI. // When token is empty and the remote URL is SSH, go-git SSH agent auth is attempted first. @@ -253,7 +257,7 @@ func Push(repo *gogit.Repository, branchName, tagName, token string) error { if remote, err := repo.Remote("origin"); err == nil { urls := remote.Config().URLs if len(urls) > 0 && isSSHURL(urls[0]) { - if err := pushWithSSHAgent(repo, branchName, tagName); err == nil { + if err := sshPush(repo, branchName, tagName); err == nil { return nil } } @@ -266,7 +270,7 @@ func isSSHURL(u string) bool { } func pushWithSSHAgent(repo *gogit.Repository, branchName, tagName string) error { - auth, err := gitssh.NewSSHAgentAuth("git") + auth, err := newSSHAgentAuth("git") if err != nil { return err } @@ -331,22 +335,31 @@ func pushWithCLI(repo *gogit.Repository, branchName, tagName string) error { return nil } -// resolveTagToCommit follows tag objects until it reaches a commit. +// resolveTagToCommitObj follows tag objects until it reaches a commit and returns it. // Handles both lightweight tags (ref → commit) and annotated tags (ref → tag object → … → commit). -func resolveTagToCommit(repo *gogit.Repository, ref *plumbing.Reference) (plumbing.Hash, error) { +func resolveTagToCommitObj(repo *gogit.Repository, ref *plumbing.Reference) (*object.Commit, error) { hash := ref.Hash() for { obj, err := repo.Object(plumbing.AnyObject, hash) if err != nil { - return plumbing.ZeroHash, err + return nil, err } switch o := obj.(type) { case *object.Commit: - return o.Hash, nil + return o, nil case *object.Tag: hash = o.Target default: - return plumbing.ZeroHash, fmt.Errorf("unexpected object type %s at %s", obj.Type(), hash) + return nil, fmt.Errorf("unexpected object type %s at %s", obj.Type(), hash) } } } + +// resolveTagToCommit follows tag objects until it reaches a commit and returns its hash. +func resolveTagToCommit(repo *gogit.Repository, ref *plumbing.Reference) (plumbing.Hash, error) { + c, err := resolveTagToCommitObj(repo, ref) + if err != nil { + return plumbing.ZeroHash, err + } + return c.Hash, nil +} diff --git a/internal/gitutil/gitutil_test.go b/internal/gitutil/gitutil_test.go index d8216bd..283337e 100644 --- a/internal/gitutil/gitutil_test.go +++ b/internal/gitutil/gitutil_test.go @@ -1,6 +1,7 @@ package gitutil import ( + "fmt" "os" "path/filepath" "strings" @@ -11,6 +12,7 @@ import ( gitconfig "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" + gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" "git.k3nny.fr/releaser/internal/branch" ) @@ -699,3 +701,408 @@ func TestPushWithBareRemote(t *testing.T) { t.Fatalf("second Push returned unexpected error: %v", err) } } + +// ── IsWorkingTreeClean: w.Status() error path ──────────────────────────────── + +func TestIsWorkingTreeCleanCorruptIndex(t *testing.T) { + // Use a filesystem repo so we can corrupt the on-disk index. + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "chore: init", "initial") + + // Overwrite .git/index with garbage so go-git fails to parse it. + indexPath := filepath.Join(dir, ".git", "index") + if err := os.WriteFile(indexPath, []byte("not a valid git index"), 0644); err != nil { + t.Fatal(err) + } + + // Reopen — fresh repository object with no cached index. + repo2, err := gogit.PlainOpen(dir) + if err != nil { + t.Fatal(err) + } + + _, err = IsWorkingTreeClean(repo2) + if err == nil { + t.Error("expected error when git index is corrupt") + } +} + +// ── LatestTag: head commit object missing ──────────────────────────────────── + +func TestLatestTagHeadCommitMissing(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + addTag(t, repo, "v1.2.0") + + // Detach HEAD to a fake hash that has no backing commit object. + fakeHash := plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + if err := repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, fakeHash)); err != nil { + t.Fatal(err) + } + + _, _, err := LatestTag(repo, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}) + if err == nil { + t.Error("expected error when HEAD commit object is missing") + } +} + +// ── LatestTag: Tags() iterator fails ──────────────────────────────────────── + +func TestLatestTagTagsIterFails(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + + // Make .git/refs/tags/ unreadable so that go-git's walkReferencesTree + // returns EPERM when it tries to list the directory, triggering the + // Tags() error path. Skip when running as root (chmod has no effect). + tagsDir := filepath.Join(dir, ".git", "refs", "tags") + if err := os.Chmod(tagsDir, 0000); err != nil { + t.Skipf("cannot chmod %s: %v", tagsDir, err) + } + t.Cleanup(func() { os.Chmod(tagsDir, 0755) }) + + // Reopen so the filesystem storer holds no cached state. + repo2, err := gogit.PlainOpen(dir) + if err != nil { + t.Skipf("PlainOpen failed (likely running as root): %v", err) + } + + _, _, err = LatestTag(repo2, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}) + if err == nil { + t.Error("expected error when refs/tags is unreadable") + } +} + +// ── LatestTag: IsAncestor fails → ForEach propagates error ─────────────────── + +func TestLatestTagIsAncestorFails(t *testing.T) { + // Topology: c0 (base) → c1 (sibling branch, tagged v1.2.0) + // → c2 (master HEAD — diverged from sibling) + // The tag is NOT an ancestor of HEAD. IsAncestor must walk master's history + // all the way back to c0; corrupting c0 makes that walk fail. + repo, dir := newTestRepo(t) + c0 := addCommit(t, repo, dir, "chore: base", "base") + + w, _ := repo.Worktree() + if err := w.Checkout(&gogit.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName("sibling"), + Hash: c0, + Create: true, + }); err != nil { + t.Fatal(err) + } + addCommit(t, repo, dir, "feat: sibling work", "sibling") + addTag(t, repo, "v1.2.0") // tag on the sibling commit (not an ancestor of master) + + if err := w.Checkout(&gogit.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName("master"), + }); err != nil { + t.Fatal(err) + } + addCommit(t, repo, dir, "fix: mainline", "mainline") // HEAD on master + + // Corrupt c0 (the common base) so that IsAncestor's commit-graph walk + // fails when it tries to read c0 as a parent of the master HEAD commit. + hashStr := c0.String() + objPath := filepath.Join(dir, ".git", "objects", hashStr[:2], hashStr[2:]) + if err := os.Chmod(objPath, 0644); err != nil { + t.Fatalf("chmod object: %v", err) + } + if err := os.WriteFile(objPath, []byte("corrupt"), 0444); err != nil { + t.Fatal(err) + } + + repo2, err := gogit.PlainOpen(dir) + if err != nil { + t.Fatal(err) + } + + // The ForEach callback propagates the IsAncestor error, so LatestTag + // must return a non-nil error (covers the refs.ForEach error path). + _, _, err = LatestTag(repo2, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}) + if err == nil { + t.Error("expected error when commit graph is corrupt during IsAncestor") + } +} + +// ── CommitsSince: repo.Head() fails after tag resolve ──────────────────────── + +func TestCommitsSinceHeadRemoved(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + addTag(t, repo, "v1.2.0") + + // Remove HEAD so repo.Head() returns ErrReferenceNotFound. + if err := repo.Storer.RemoveReference(plumbing.HEAD); err != nil { + t.Fatal(err) + } + + _, err := CommitsSince(repo, "v1.2.0") + if err == nil { + t.Error("expected error when HEAD reference is missing") + } +} + +// ── CommitsSince: repo.Log() fails ─────────────────────────────────────────── + +func TestCommitsSinceFakeHead(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + addTag(t, repo, "v1.2.0") + addCommit(t, repo, dir, "fix: c2", "v2") + + // Point HEAD directly to a non-existent commit hash. + // repo.Head() succeeds (returns the hash) but repo.Log() fails eagerly. + fakeHash := plumbing.NewHash("cafebabecafebabecafebabecafebabecafebabe") + if err := repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, fakeHash)); err != nil { + t.Fatal(err) + } + + _, err := CommitsSince(repo, "v1.2.0") + if err == nil { + t.Error("expected error when HEAD commit object is missing") + } +} + +// ── AllCommits: repo.Log() fails ───────────────────────────────────────────── + +func TestAllCommitsFakeHead(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + + // Point HEAD to a non-existent commit hash so repo.Log() fails eagerly. + fakeHash := plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + if err := repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, fakeHash)); err != nil { + t.Fatal(err) + } + + _, err := AllCommits(repo) + if err == nil { + t.Error("expected error when HEAD commit object is missing") + } +} + +// ── CommitFiles: w.Commit() fails ──────────────────────────────────────────── + +func TestCommitFilesUnchanged(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "chore: init", "initial") + + // test.txt already committed and unchanged — w.Add succeeds, w.Commit fails + // (go-git rejects empty commits when AllowEmptyCommits is false). + _, err := CommitFiles(repo, []string{"test.txt"}, "chore: empty", "Test", "t@t.com") + if err == nil { + t.Error("expected error when committing unchanged file (empty commit)") + } +} + +// ── Push: SSH agent success / failure paths ────────────────────────────────── + +func TestPushSSHAgentSucceeds(t *testing.T) { + // Mock sshPush so it succeeds without a real SSH agent. + orig := sshPush + sshPush = func(_ *gogit.Repository, _, _ string) error { return nil } + defer func() { sshPush = orig }() + + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{"git@example.com:owner/repo.git"}, + }); err != nil { + t.Fatal(err) + } + + if err := Push(repo, "master", "v1.0.0", ""); err != nil { + t.Fatalf("Push with mocked SSH agent should succeed: %v", err) + } +} + +func TestPushSSHAgentFailsFallsBackToCLI(t *testing.T) { + // SSH URL remote + sshPush fails → falls through to pushWithCLI. + orig := sshPush + sshPush = func(_ *gogit.Repository, _, _ string) error { return fmt.Errorf("no agent") } + defer func() { sshPush = orig }() + + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{"git@example.com:owner/repo.git"}, + }); err != nil { + t.Fatal(err) + } + + // CLI push will fail (no real remote) — we just verify it ran at all. + err := Push(repo, "master", "v1.0.0", "") + if err == nil { + t.Error("expected error after SSH fallback to CLI with unreachable remote") + } +} + +// ── pushWithSSHAgent internals ──────────────────────────────────────────────── + +func TestPushWithSSHAgentAuthFails(t *testing.T) { + orig := newSSHAgentAuth + newSSHAgentAuth = func(_ string) (*gitssh.PublicKeysCallback, error) { + return nil, fmt.Errorf("SSH_AUTH_SOCK not set") + } + defer func() { newSSHAgentAuth = orig }() + + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + + err := pushWithSSHAgent(repo, "master", "v1.0.0") + if err == nil { + t.Error("expected error when SSH agent auth fails") + } +} + +func TestPushWithSSHAgentNoRemote(t *testing.T) { + orig := newSSHAgentAuth + newSSHAgentAuth = func(user string) (*gitssh.PublicKeysCallback, error) { + return &gitssh.PublicKeysCallback{User: user}, nil + } + defer func() { newSSHAgentAuth = orig }() + + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + // No remote configured → repo.Remote("origin") fails. + + err := pushWithSSHAgent(repo, "master", "v1.0.0") + if err == nil { + t.Error("expected error when no origin remote is configured") + } +} + +func TestPushWithSSHAgentPushFails(t *testing.T) { + orig := newSSHAgentAuth + newSSHAgentAuth = func(user string) (*gitssh.PublicKeysCallback, error) { + return &gitssh.PublicKeysCallback{User: user}, nil + } + defer func() { newSSHAgentAuth = orig }() + + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + addTag(t, repo, "v1.0.0") + if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{"/nonexistent/bare/repo"}, + }); err != nil { + t.Fatal(err) + } + + err := pushWithSSHAgent(repo, "master", "v1.0.0") + if err == nil { + t.Error("expected error when remote push fails") + } +} + +func TestPushWithSSHAgentSuccess(t *testing.T) { + remoteDir := t.TempDir() + if _, err := gogit.PlainInit(remoteDir, true); err != nil { + t.Fatal(err) + } + + orig := newSSHAgentAuth + newSSHAgentAuth = func(user string) (*gitssh.PublicKeysCallback, error) { + return &gitssh.PublicKeysCallback{User: user}, nil + } + defer func() { newSSHAgentAuth = orig }() + + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + addTag(t, repo, "v1.0.0") + if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{remoteDir}, + }); err != nil { + t.Fatal(err) + } + + // Local transport ignores auth — push succeeds regardless of the mock callback. + if err := pushWithSSHAgent(repo, "master", "v1.0.0"); err != nil { + t.Fatalf("expected success pushing to local bare remote: %v", err) + } +} + +// ── pushWithGoGit error paths ───────────────────────────────────────────────── + +func TestPushWithGoGitNoRemote(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + // No remote → repo.Remote("origin") fails inside pushWithGoGit. + err := Push(repo, "master", "v1.0.0", "some-token") + if err == nil { + t.Error("expected error when no origin remote is configured") + } +} + +func TestPushWithGoGitPushFails(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + addTag(t, repo, "v1.0.0") + if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{"/nonexistent/bare/repo"}, + }); err != nil { + t.Fatal(err) + } + err := Push(repo, "master", "v1.0.0", "some-token") + if err == nil { + t.Error("expected error when remote push fails") + } +} + +// ── pushWithCLI: bare repo → no worktree ──────────────────────────────────── + +func TestPushWithCLIBareRepo(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, true) + if err != nil { + t.Fatal(err) + } + // No token, no SSH URL → goes to pushWithCLI → Worktree() fails for bare repo. + err = Push(repo, "master", "v1.0.0", "") + if err == nil { + t.Error("expected error for bare repo (no worktree)") + } +} + +func TestPushWithCLISuccess(t *testing.T) { + // Non-bare repo + local bare remote + no token + no SSH URL → pushWithCLI → success. + repo, dir := newTestRepo(t) + sig := testSig() + + wt, _ := repo.Worktree() + if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + wt.Add("f.txt") + hash, err := wt.Commit("init", &gogit.CommitOptions{Author: sig}) + if err != nil { + t.Fatal(err) + } + if _, err := repo.CreateTag("v1.0.0", hash, nil); err != nil { + t.Fatal(err) + } + + remoteDir := t.TempDir() + if _, err := gogit.PlainInit(remoteDir, true); err != nil { + t.Fatal(err) + } + if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{remoteDir}, + }); err != nil { + t.Fatal(err) + } + + // Detect default branch name (go-git uses "master" but git config may differ). + head, _ := repo.Head() + branchName := head.Name().Short() + + if err := Push(repo, branchName, "v1.0.0", ""); err != nil { + t.Fatalf("pushWithCLI success: %v", err) + } +} diff --git a/internal/node/node.go b/internal/node/node.go new file mode 100644 index 0000000..bd0c2b9 --- /dev/null +++ b/internal/node/node.go @@ -0,0 +1,42 @@ +package node + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// ReadVersion returns the version field from a package.json file. +func ReadVersion(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read %s: %w", path, err) + } + var pkg struct { + Version string `json:"version"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return "", fmt.Errorf("parse %s: %w", path, err) + } + if pkg.Version == "" { + return "", fmt.Errorf("no version field in %s", path) + } + return pkg.Version, nil +} + +// WriteVersion replaces the version field in a package.json file in-place. +// oldVersion must match what ReadVersion returned. Formatting is preserved. +func WriteVersion(path, oldVersion, newVersion string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + old := `"version": "` + oldVersion + `"` + repl := `"version": "` + newVersion + `"` + if !strings.Contains(string(data), old) { + return fmt.Errorf("version %q not found in %s", oldVersion, path) + } + updated := strings.Replace(string(data), old, repl, 1) + return os.WriteFile(path, []byte(updated), 0644) +} diff --git a/internal/node/node_test.go b/internal/node/node_test.go new file mode 100644 index 0000000..456a804 --- /dev/null +++ b/internal/node/node_test.go @@ -0,0 +1,99 @@ +package node + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeJSON(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "package.json") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + return path +} + +const simplePackage = `{ + "name": "my-app", + "version": "1.2.3", + "description": "test" +}` + +func TestReadVersion(t *testing.T) { + got, err := ReadVersion(writeJSON(t, simplePackage)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.2.3" { + t.Errorf("got %q, want 1.2.3", got) + } +} + +func TestReadVersionMissingFile(t *testing.T) { + _, err := ReadVersion(filepath.Join(t.TempDir(), "package.json")) + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestReadVersionInvalidJSON(t *testing.T) { + _, err := ReadVersion(writeJSON(t, `{not valid json`)) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestReadVersionNoVersionField(t *testing.T) { + _, err := ReadVersion(writeJSON(t, `{"name":"app"}`)) + if err == nil { + t.Error("expected error when version field is absent") + } +} + +func TestWriteVersion(t *testing.T) { + path := writeJSON(t, simplePackage) + if err := WriteVersion(path, "1.2.3", "1.2.4"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), `"version": "1.2.4"`) { + t.Errorf("expected updated version in file; got:\n%s", data) + } + // name and description must be preserved + if !strings.Contains(string(data), `"name": "my-app"`) { + t.Error("name field was lost") + } +} + +func TestWriteVersionMissingFile(t *testing.T) { + err := WriteVersion(filepath.Join(t.TempDir(), "package.json"), "1.0.0", "1.0.1") + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestWriteVersionNotFound(t *testing.T) { + err := WriteVersion(writeJSON(t, simplePackage), "9.9.9", "9.9.10") + if err == nil { + t.Error("expected error when old version not found") + } +} + +// FuzzReadVersion verifies ReadVersion never panics on arbitrary content. +func FuzzReadVersion(f *testing.F) { + f.Add(simplePackage) + f.Add(`{}`) + f.Add(`{"version":"1.0.0"}`) + f.Add(`not json at all`) + f.Add(``) + f.Add("\x00\xff") + + f.Fuzz(func(t *testing.T, content string) { + path := filepath.Join(t.TempDir(), "package.json") + os.WriteFile(path, []byte(content), 0644) //nolint:errcheck + ReadVersion(path) //nolint:errcheck + }) +} diff --git a/internal/version/version.go b/internal/version/version.go index a200d91..09baf1b 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -6,18 +6,38 @@ import ( "git.k3nny.fr/releaser/internal/commits" ) +// BumpLevel controls which version component is incremented. +type BumpLevel int + +const ( + BumpPatch BumpLevel = iota + BumpMinor +) + // Next computes the next version string (without tag prefix, e.g. "1.2.4"). // currentPatch is -1 when no tag exists yet (first release will be X.Y.0). // releasable is the set of commit types that trigger a bump; nil defaults to all three. +// bumpRules maps each type to its bump level; nil defaults to BumpPatch for all. // Returns ("", false) when there are no releasable commits. -func Next(major, minor, currentPatch int, types []commits.Type, releasable map[commits.Type]bool) (string, bool) { +func Next(major, minor, currentPatch int, types []commits.Type, releasable map[commits.Type]bool, bumpRules map[commits.Type]BumpLevel) (string, bool) { if releasable == nil { releasable = commits.ReleasableSet(nil) } + found := false + useMinor := false for _, t := range types { if releasable[t] { - return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true + found = true + if bumpRules[t] == BumpMinor { + useMinor = true + } } } - return "", false + if !found { + return "", false + } + if useMinor { + return fmt.Sprintf("%d.%d.0", major, minor+1), true + } + return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true } diff --git a/internal/version/version_test.go b/internal/version/version_test.go index 1bebc4b..afad054 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -30,7 +30,7 @@ func TestNext(t *testing.T) { wantOk: true, }, { - desc: "breaking change still bumps patch on release branch", + desc: "breaking change still bumps patch by default", major: 2, minor: 0, currentPatch: 1, types: []commits.Type{commits.TypeBreaking}, want: "2.0.2", @@ -61,7 +61,7 @@ func TestNext(t *testing.T) { for _, c := range cases { t.Run(c.desc, func(t *testing.T) { - got, ok := Next(c.major, c.minor, c.currentPatch, c.types, nil) + got, ok := Next(c.major, c.minor, c.currentPatch, c.types, nil, nil) if ok != c.wantOk { t.Errorf("ok=%v, want %v", ok, c.wantOk) } @@ -71,3 +71,73 @@ func TestNext(t *testing.T) { }) } } + +func TestNextMinorBump(t *testing.T) { + rules := map[commits.Type]BumpLevel{ + commits.TypeBreaking: BumpMinor, + } + + cases := []struct { + desc string + major, minor int + currentPatch int + types []commits.Type + want string + }{ + { + desc: "breaking → minor bump", + major: 1, minor: 2, currentPatch: 3, + types: []commits.Type{commits.TypeBreaking}, + want: "1.3.0", + }, + { + desc: "feat (patch rule) with breaking (minor rule) → minor wins", + major: 1, minor: 2, currentPatch: 3, + types: []commits.Type{commits.TypeFeat, commits.TypeBreaking}, + want: "1.3.0", + }, + { + desc: "fix only — no minor rule → patch bump", + major: 1, minor: 2, currentPatch: 3, + types: []commits.Type{commits.TypeFix}, + want: "1.2.4", + }, + { + desc: "first release with minor bump", + major: 2, minor: 0, currentPatch: -1, + types: []commits.Type{commits.TypeBreaking}, + want: "2.1.0", + }, + } + + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + got, ok := Next(c.major, c.minor, c.currentPatch, c.types, nil, rules) + if !ok { + t.Fatal("expected ok=true") + } + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} + +func TestNextAllMinorRules(t *testing.T) { + rules := map[commits.Type]BumpLevel{ + commits.TypeBreaking: BumpMinor, + commits.TypeFeat: BumpMinor, + commits.TypeFix: BumpMinor, + } + got, ok := Next(1, 4, 2, []commits.Type{commits.TypeFix}, nil, rules) + if !ok || got != "1.5.0" { + t.Errorf("got %q ok=%v, want 1.5.0 true", got, ok) + } +} + +func TestNextNilBumpRulesDefaultsToPatch(t *testing.T) { + got, ok := Next(1, 2, 5, []commits.Type{commits.TypeBreaking}, nil, nil) + if !ok || got != "1.2.6" { + t.Errorf("got %q ok=%v, want 1.2.6 true", got, ok) + } +}