Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 131 additions & 28 deletions internal/changelog/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ package changelog

import (
"bytes"
"encoding/json"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestChangelogNewCommand_Structure(t *testing.T) {
Expand All @@ -20,50 +27,146 @@ func TestChangelogNewCommand_Structure(t *testing.T) {
assert.NotNil(t, cmd.Flags().Lookup("contributors"))
}

func TestChangelogNewCommand_RunE_EmptySHARange(t *testing.T) {
// HEAD..HEAD yields zero commits without error; exercises the RunE closure
// body and runGenerateChangelog through the JSON output path.
// changelogRepo builds a hermetic git repo with a chore base commit, a feat
// touching src/, and a fix touching docs/, changes into it, and returns the
// base commit SHA.
func changelogRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Chdir(dir)
gitCL(t, "init", "-b", "main")
gitCL(t, "config", "user.email", "dev@example.com")
gitCL(t, "config", "user.name", "Dev Example")
gitCL(t, "config", "commit.gpgsign", "false")

commitFileCL(t, "README.md", "seed", "chore: seed")
base := gitOutCL(t, "rev-parse", "HEAD")
commitFileCL(t, filepath.Join("src", "parser.go"), "package src", "feat: add parser")
commitFileCL(t, filepath.Join("docs", "guide.md"), "guide", "fix: correct guide typo")
return base
}

func gitCL(t *testing.T, args ...string) {
t.Helper()
out, err := exec.Command("git", args...).CombinedOutput()
require.NoError(t, err, "git %v: %s", args, out)
}

func gitOutCL(t *testing.T, args ...string) string {
t.Helper()
out, err := exec.Command("git", args...).Output()
require.NoError(t, err, "git %v", args)
return strings.TrimSpace(string(out))
}

func commitFileCL(t *testing.T, path, content, message string) {
t.Helper()
if d := filepath.Dir(path); d != "." {
require.NoError(t, os.MkdirAll(d, 0o750))
}
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
gitCL(t, "add", path)
gitCL(t, "commit", "-m", message)
}

// executeChangelogCapture runs generate-changelog with args and returns what
// it wrote to os.Stdout (the command prints there directly, not to the cobra
// writer) decoded as a ChangelogResult.
func executeChangelogCapture(t *testing.T, args []string) ChangelogResult {
t.Helper()
orig := os.Stdout
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stdout = w
defer func() { os.Stdout = orig }()

cmd := NewCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{
var cmdOut bytes.Buffer
cmd.SetOut(&cmdOut)
cmd.SetErr(&cmdOut)
cmd.SetArgs(args)
runErr := cmd.Execute()

require.NoError(t, w.Close())
data, readErr := io.ReadAll(r)
os.Stdout = orig
require.NoError(t, readErr)
require.NoError(t, runErr)

var result ChangelogResult
require.NoError(t, json.Unmarshal(data, &result), "stdout is not a ChangelogResult: %s", data)
return result
}

// TestChangelogNewCommand_RunE_EmptyRangeEmitsEmptyResult pins the empty-range
// contract: HEAD..HEAD succeeds and emits an empty changelog with every
// category flag false, rather than erroring or fabricating content.
func TestChangelogNewCommand_RunE_EmptyRangeEmitsEmptyResult(t *testing.T) {
changelogRepo(t)

result := executeChangelogCapture(t, []string{
"--base-sha", "HEAD",
"--head-sha", "HEAD",
"--repo", "owner/repo",
})
// The empty commit range produces valid JSON output; swallow any git failure.
_ = cmd.Execute()
assert.Equal(t, ChangelogResult{}, result)
}

func TestChangelogNewCommand_RunE_WithExcludePaths(t *testing.T) {
// Exercises the excludePaths splitting branch in the RunE closure.
cmd := NewCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{
"--base-sha", "HEAD",
// TestChangelogNewCommand_RunE_ExcludePathsDropOnlyMatchingCommits proves the
// exclude-paths flag filters commits by pathspec: the docs-only fix disappears
// while the src feat survives. The unfiltered control run shows both, so the
// assertion fails if exclusion stops working (or over-filters). The exclude
// list carries surrounding whitespace to pin the documented trimming.
func TestChangelogNewCommand_RunE_ExcludePathsDropOnlyMatchingCommits(t *testing.T) {
base := changelogRepo(t)

unfiltered := executeChangelogCapture(t, []string{
"--base-sha", base,
"--head-sha", "HEAD",
"--repo", "owner/repo",
"--exclude-paths", "docs/, internal/old/",
})
_ = cmd.Execute()
assert.True(t, unfiltered.HasFeatures)
assert.True(t, unfiltered.HasFixes)
assert.False(t, unfiltered.HasBreaking)
assert.Contains(t, unfiltered.Changelog, "add parser")
assert.Contains(t, unfiltered.Changelog, "correct guide typo")

filtered := executeChangelogCapture(t, []string{
"--base-sha", base,
"--head-sha", "HEAD",
"--repo", "owner/repo",
"--exclude-paths", "docs/ , internal/old/",
})
assert.True(t, filtered.HasFeatures)
assert.False(t, filtered.HasFixes)
assert.Contains(t, filtered.Changelog, "add parser")
assert.NotContains(t, filtered.Changelog, "correct guide typo")
}

func TestChangelogNewCommand_RunE_WithContributors(t *testing.T) {
// Exercises the contributors branch in runGenerateChangelog.
cmd := NewCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{
"--base-sha", "HEAD",
// TestChangelogNewCommand_RunE_ContributorsAttributeUsernames proves the
// contributors flag flows a looked-up GitHub username into the rendered
// changelog lines. The gh CLI is stubbed with a fixed GraphQL response so the
// lookup is hermetic: the repo's single author resolves to octocat, and both
// the feat and fix lines must carry the attribution.
func TestChangelogNewCommand_RunE_ContributorsAttributeUsernames(t *testing.T) {
base := changelogRepo(t)

stubDir := t.TempDir()
stub := "#!/bin/sh\n" +
`echo '{"data":{"repository":{"c0":{"author":{"user":{"login":"octocat"}}}}}}'` + "\n"
require.NoError(t, os.WriteFile(filepath.Join(stubDir, "gh"), []byte(stub), 0o755)) //nolint:gosec // test stub must be executable
t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH"))

result := executeChangelogCapture(t, []string{
"--base-sha", base,
"--head-sha", "HEAD",
"--repo", "owner/repo",
"--contributors",
})
_ = cmd.Execute()
assert.Contains(t, result.Changelog, "add parser")
assert.Contains(t, result.Changelog, "correct guide typo")
assert.Equal(t, 2, strings.Count(result.Changelog, "(@octocat)"),
"both commit lines should carry the stubbed username attribution")
}

func TestChangelogNewCommand_MissingRequiredFlags(t *testing.T) {
Expand Down
148 changes: 120 additions & 28 deletions internal/version/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ package version

import (
"bytes"
"encoding/json"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -83,54 +87,142 @@ func TestVersionNewCommand_RunE_EnvironmentNotFound(t *testing.T) {
assert.Contains(t, err.Error(), "environment")
}

func TestVersionNewCommand_RunE_ValidConfigTextOutput(t *testing.T) {
configPath := minimalVersionConfig(t, []string{"dev", "test"})
// versionStateRepo builds a hermetic git repo (a chore base commit, then a
// feat commit at HEAD), changes into it, and writes a manifest whose recorded
// state has dev at v1.2.0-rc.1 and test at v1.1.0 anchored to the base commit.
// Returns the manifest path.
func versionStateRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Chdir(dir)
runGitV(t, "init", "-b", "main")
runGitV(t, "config", "user.email", "test@example.com")
runGitV(t, "config", "user.name", "Test User")
runGitV(t, "config", "commit.gpgsign", "false")

writeCommitV(t, "README.md", "base", "chore: seed")
baseSHA := gitOutV(t, "rev-parse", "HEAD")
writeCommitV(t, "login.go", "package main", "feat: add login flow")

configPath := filepath.Join(dir, "manifest.yaml")
manifest := "ci:\n" +
" config:\n" +
" trunk_branch: main\n" +
" environments:\n" +
" - dev\n" +
" - test\n" +
" state:\n" +
" dev:\n" +
" version: v1.2.0-rc.1\n" +
" test:\n" +
" version: v1.1.0\n" +
" sha: " + baseSHA + "\n"
require.NoError(t, os.WriteFile(configPath, []byte(manifest), 0o600))
return configPath
}

func gitOutV(t *testing.T, args ...string) string {
t.Helper()
out, err := exec.Command("git", args...).Output()
require.NoError(t, err, "git %v", args)
return strings.TrimSpace(string(out))
}

// executeVersionCapture runs next-version with args and returns what it wrote
// to os.Stdout (the command prints there directly, not to the cobra writer).
func executeVersionCapture(t *testing.T, args []string) (string, error) {
t.Helper()
orig := os.Stdout
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stdout = w
defer func() { os.Stdout = orig }()

cmd := NewCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
// HEAD~1..HEAD gets exactly one real commit; exercises the commit-parsing loop.
cmd.SetArgs([]string{
var cmdOut bytes.Buffer
cmd.SetOut(&cmdOut)
cmd.SetErr(&cmdOut)
cmd.SetArgs(args)
runErr := cmd.Execute()

require.NoError(t, w.Close())
data, readErr := io.ReadAll(r)
os.Stdout = orig
require.NoError(t, readErr)
return string(data), runErr
}

// TestVersionNewCommand_RunE_TextOutput_IncrementsRCOnSameBase pins the text
// path end to end: the feat commit in HEAD~1..HEAD bumps test's v1.1.0 to base
// v1.2.0, and dev's recorded v1.2.0-rc.1 on that same base yields rc.2.
func TestVersionNewCommand_RunE_TextOutput_IncrementsRCOnSameBase(t *testing.T) {
configPath := versionStateRepo(t)

out, err := executeVersionCapture(t, []string{
"--environment", "dev",
"--config", configPath,
"--base-sha", "HEAD~1",
"--head-sha", "HEAD",
})
// May succeed or fail depending on git availability; exercise the code path.
_ = cmd.Execute()
require.NoError(t, err)
assert.Equal(t, "v1.2.0-rc.2\n", out)
}

func TestVersionNewCommand_RunE_ValidConfigJSONOutput(t *testing.T) {
configPath := minimalVersionConfig(t, []string{"dev", "test"})
// TestVersionNewCommand_RunE_JSONOutput_FullShape pins every field of the JSON
// payload. --base-sha is omitted so the range defaults to the next env's
// recorded SHA (the base commit), which must yield the same single feat commit.
func TestVersionNewCommand_RunE_JSONOutput_FullShape(t *testing.T) {
configPath := versionStateRepo(t)

cmd := NewCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{
out, err := executeVersionCapture(t, []string{
"--environment", "dev",
"--config", configPath,
"--base-sha", "HEAD~1",
"--head-sha", "HEAD",
"--json",
})
_ = cmd.Execute()
require.NoError(t, err)

var got map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(out), &got))
assert.Equal(t, map[string]interface{}{
"version": "v1.2.0-rc.2",
"base_version": "v1.2.0",
"current_dev": "v1.2.0-rc.1",
"next_env": "v1.1.0",
"bump_type": "minor",
"commit_count": float64(1),
"release_candidate": float64(2),
}, got)
}

func TestVersionNewCommand_RunE_EmptyRange(t *testing.T) {
configPath := minimalVersionConfig(t, []string{"dev", "test"})
// TestVersionNewCommand_RunE_NoState_StartsAtFirstRC pins the fallback path:
// with no recorded state and no --base-sha the range starts at the initial
// commit, the fix commit bumps v0.0.0 to a patch, the v0.1.0 minimum floor
// applies, and the empty dev version starts the counter at rc.0.
func TestVersionNewCommand_RunE_NoState_StartsAtFirstRC(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
runGitV(t, "init", "-b", "main")
runGitV(t, "config", "user.email", "test@example.com")
runGitV(t, "config", "user.name", "Test User")
runGitV(t, "config", "commit.gpgsign", "false")
writeCommitV(t, "README.md", "base", "chore: seed")
writeCommitV(t, "main.go", "package main", "fix: correct rounding")

cmd := NewCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
// HEAD..HEAD yields zero commits; exercises the no-baseSHA fallback branch.
cmd.SetArgs([]string{
configPath := filepath.Join(dir, "manifest.yaml")
manifest := "ci:\n" +
" config:\n" +
" trunk_branch: main\n" +
" environments:\n" +
" - dev\n" +
" - test\n"
require.NoError(t, os.WriteFile(configPath, []byte(manifest), 0o600))

out, err := executeVersionCapture(t, []string{
"--environment", "dev",
"--config", configPath,
})
_ = cmd.Execute()
require.NoError(t, err)
assert.Equal(t, "v0.1.0-rc.0\n", out)
}

func TestVersionNewCommand_RunE_NoConfigFlag(t *testing.T) {
Expand Down