feat(releaser): initial release v0.4.0
Complete GitFlow release automation tool for Conventional Commits workflows: - Core pipeline: branch parsing, tag discovery, commit analysis, version bump - Maven pom.xml read/write, git commit/tag, HTTPS push with token auth - GitLab release creation via API with auto-generated release notes - Configurable via .releaser.yml (tag_prefix, branch_pattern, commit_message, pom_path, gitlab) - CLI flags: --dry-run, --no-push, --no-commit, --tag-only, --branch, --pom, --tag-prefix, --branch-pattern - Dockerfile (multi-stage Alpine), .releaser.gitlab-ci.yml reusable template - Gitea CI (vet + staticcheck + test + build) and release (5-platform cross-compilation) workflows - Taskfile with build/test/cov/lint/fuzz/ci/docker tasks - 96% test coverage with real in-memory git repos and fuzz tests for all parsers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
package branch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// DefaultBranchPattern matches "release/X.Y" or "origin/release/X.Y".
|
||||
// Group 1 = major, group 2 = minor.
|
||||
const DefaultBranchPattern = `^(?:.*/)?release/(\d+)\.(\d+)$`
|
||||
|
||||
// Info holds the major.minor version pinned by the branch name, plus the tag prefix from config.
|
||||
type Info struct {
|
||||
Major int
|
||||
Minor int
|
||||
TagPrefix string // set from config after Parse (default "v")
|
||||
}
|
||||
|
||||
// Parse extracts major and minor from a branch name using the provided regex pattern.
|
||||
// The pattern must contain exactly two capture groups: group 1 = major, group 2 = minor.
|
||||
// TagPrefix is left at its zero value — the caller sets it from config.
|
||||
func Parse(name, pattern string) (Info, error) {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return Info{}, fmt.Errorf("invalid branch pattern %q: %w", pattern, err)
|
||||
}
|
||||
|
||||
m := re.FindStringSubmatch(name)
|
||||
if len(m) < 3 {
|
||||
return Info{}, fmt.Errorf("branch %q does not match pattern %q", name, pattern)
|
||||
}
|
||||
|
||||
major, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
return Info{}, fmt.Errorf("invalid major version in branch %q: %w", name, err)
|
||||
}
|
||||
minor, err := strconv.Atoi(m[2])
|
||||
if err != nil {
|
||||
return Info{}, fmt.Errorf("invalid minor version in branch %q: %w", name, err)
|
||||
}
|
||||
|
||||
return Info{Major: major, Minor: minor}, nil
|
||||
}
|
||||
|
||||
// MatchesTag checks whether a tag (e.g. "v1.2.3" or "1.2.3") belongs to this branch's version range.
|
||||
// Uses TagPrefix to match the expected format. Returns the patch number and true on match.
|
||||
func (i Info) MatchesTag(tag string) (patch int, ok bool) {
|
||||
prefix := fmt.Sprintf("%s%d.%d.", i.TagPrefix, i.Major, i.Minor)
|
||||
if len(tag) <= len(prefix) || tag[:len(prefix)] != prefix {
|
||||
return 0, false
|
||||
}
|
||||
p, err := strconv.Atoi(tag[len(prefix):])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
// TagName formats a version string (e.g. "1.2.4") into a full tag name (e.g. "v1.2.4" or "1.2.4").
|
||||
func (i Info) TagName(version string) string {
|
||||
return i.TagPrefix + version
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package branch
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FuzzParse verifies the parser never panics, and that successful parses produce
|
||||
// non-negative major/minor values.
|
||||
func FuzzParse(f *testing.F) {
|
||||
seeds := []string{
|
||||
"release/1.2",
|
||||
"hotfix/10.3",
|
||||
"origin/release/0.0",
|
||||
"main",
|
||||
"release/",
|
||||
"release/1.2.3",
|
||||
"",
|
||||
"\x00",
|
||||
"release/999999.999999",
|
||||
strings.Repeat("release/", 100) + "1.2",
|
||||
}
|
||||
for _, s := range seeds {
|
||||
f.Add(s)
|
||||
}
|
||||
|
||||
f.Fuzz(func(t *testing.T, name string) {
|
||||
info, err := Parse(name, DefaultBranchPattern)
|
||||
if err != nil {
|
||||
return // invalid input is expected — just must not panic
|
||||
}
|
||||
if info.Major < 0 || info.Minor < 0 {
|
||||
t.Errorf("Parse(%q) returned negative major/minor: %+v", name, info)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
pattern string
|
||||
major int
|
||||
minor int
|
||||
err bool
|
||||
}{
|
||||
// default pattern
|
||||
{"release/1.2", DefaultBranchPattern, 1, 2, false},
|
||||
{"release/10.3", DefaultBranchPattern, 10, 3, false},
|
||||
{"origin/release/1.2", DefaultBranchPattern, 1, 2, false},
|
||||
{"refs/heads/release/1.2", DefaultBranchPattern, 1, 2, false},
|
||||
{"main", DefaultBranchPattern, 0, 0, true},
|
||||
{"release/1", DefaultBranchPattern, 0, 0, true},
|
||||
{"release/1.2.3", DefaultBranchPattern, 0, 0, true},
|
||||
{"feature/1.2", DefaultBranchPattern, 0, 0, true},
|
||||
|
||||
// custom pattern — hotfix + release
|
||||
{"hotfix/2.3", `^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$`, 2, 3, false},
|
||||
{"release/2.3", `^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$`, 2, 3, false},
|
||||
{"feature/2.3", `^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$`, 0, 0, true},
|
||||
|
||||
// invalid regex
|
||||
{"release/1.2", `(unclosed`, 0, 0, true},
|
||||
|
||||
// valid regex but capture groups match non-digits → Atoi error
|
||||
{"release/abc.1", `^release/(\w+)\.(\d+)$`, 0, 0, true}, // major non-numeric
|
||||
{"release/1.abc", `^release/(\d+)\.(\w+)$`, 0, 0, true}, // minor non-numeric
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got, err := Parse(c.input, c.pattern)
|
||||
if c.err {
|
||||
if err == nil {
|
||||
t.Errorf("Parse(%q, %q): expected error, got none", c.input, c.pattern)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("Parse(%q, %q): unexpected error: %v", c.input, c.pattern, err)
|
||||
continue
|
||||
}
|
||||
if got.Major != c.major || got.Minor != c.minor {
|
||||
t.Errorf("Parse(%q) = {%d, %d}, want {%d, %d}", c.input, got.Major, got.Minor, c.major, c.minor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesTag(t *testing.T) {
|
||||
cases := []struct {
|
||||
prefix string
|
||||
tag string
|
||||
patch int
|
||||
ok bool
|
||||
}{
|
||||
{"v", "v1.2.0", 0, true},
|
||||
{"v", "v1.2.5", 5, true},
|
||||
{"v", "v1.2.99", 99, true},
|
||||
{"v", "v1.3.0", 0, false},
|
||||
{"v", "v2.2.0", 0, false},
|
||||
{"v", "1.2.0", 0, false},
|
||||
{"v", "v1.2.", 0, false},
|
||||
{"v", "v1.2.x", 0, false},
|
||||
{"", "1.2.0", 0, true},
|
||||
{"", "1.2.7", 7, true},
|
||||
{"", "v1.2.0", 0, false},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
info := Info{Major: 1, Minor: 2, TagPrefix: c.prefix}
|
||||
patch, ok := info.MatchesTag(c.tag)
|
||||
if ok != c.ok {
|
||||
t.Errorf("[prefix=%q] MatchesTag(%q): ok=%v, want %v", c.prefix, c.tag, ok, c.ok)
|
||||
continue
|
||||
}
|
||||
if ok && patch != c.patch {
|
||||
t.Errorf("[prefix=%q] MatchesTag(%q): patch=%d, want %d", c.prefix, c.tag, patch, c.patch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagName(t *testing.T) {
|
||||
cases := []struct {
|
||||
prefix string
|
||||
version string
|
||||
want string
|
||||
}{
|
||||
{"v", "1.2.4", "v1.2.4"},
|
||||
{"", "1.2.4", "1.2.4"},
|
||||
{"release-", "1.2.4", "release-1.2.4"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
info := Info{Major: 1, Minor: 2, TagPrefix: c.prefix}
|
||||
if got := info.TagName(c.version); got != c.want {
|
||||
t.Errorf("TagName(%q) = %q, want %q", c.version, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package commits
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Type represents the semantic weight of a commit for versioning purposes.
|
||||
type Type int
|
||||
|
||||
const (
|
||||
TypeNone Type = iota // no version bump
|
||||
TypeFix // fix: → patch bump
|
||||
TypeFeat // feat: → patch bump (minor is pinned to branch)
|
||||
TypeBreaking // feat!: or BREAKING CHANGE → patch bump
|
||||
)
|
||||
|
||||
// headerRe matches conventional commit headers in non-strict mode:
|
||||
// case-insensitive, optional scope, optional breaking marker, flexible whitespace around colon.
|
||||
var headerRe = regexp.MustCompile(`(?i)^(\w+)(?:\([^)]*\))?(!)?\s*:\s*\S`)
|
||||
|
||||
// Parse extracts the commit type from a commit message.
|
||||
// Unparseable messages return TypeNone — never an error.
|
||||
func Parse(message string) Type {
|
||||
msg := strings.TrimSpace(message)
|
||||
|
||||
// BREAKING CHANGE anywhere in the body/footer takes priority (spec §10).
|
||||
if strings.Contains(msg, "BREAKING CHANGE") {
|
||||
return TypeBreaking
|
||||
}
|
||||
|
||||
first := strings.SplitN(msg, "\n", 2)[0]
|
||||
m := headerRe.FindStringSubmatch(first)
|
||||
if m == nil {
|
||||
return TypeNone
|
||||
}
|
||||
|
||||
if m[2] == "!" {
|
||||
return TypeBreaking
|
||||
}
|
||||
|
||||
switch strings.ToLower(m[1]) {
|
||||
case "feat":
|
||||
return TypeFeat
|
||||
case "fix":
|
||||
return TypeFix
|
||||
default:
|
||||
return TypeNone
|
||||
}
|
||||
}
|
||||
|
||||
func (t Type) String() string {
|
||||
switch t {
|
||||
case TypeFix:
|
||||
return "fix"
|
||||
case TypeFeat:
|
||||
return "feat"
|
||||
case TypeBreaking:
|
||||
return "breaking"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package commits
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FuzzParse verifies that the parser never panics and always returns a valid Type.
|
||||
func FuzzParse(f *testing.F) {
|
||||
// Seed corpus: representative conventional commit messages
|
||||
seeds := []string{
|
||||
"feat: add OAuth2 login",
|
||||
"fix: correct null pointer",
|
||||
"feat!: remove legacy API",
|
||||
"feat(auth): add login\n\nBREAKING CHANGE: old endpoint removed",
|
||||
"chore: update deps",
|
||||
"Feat:missing space",
|
||||
"FIX(scope)!: mixed case breaking",
|
||||
"",
|
||||
"\x00",
|
||||
"BREAKING CHANGE: bare footer",
|
||||
"not a conventional commit at all",
|
||||
"feat: \n\n body only",
|
||||
strings.Repeat("a", 4096),
|
||||
}
|
||||
for _, s := range seeds {
|
||||
f.Add(s)
|
||||
}
|
||||
|
||||
f.Fuzz(func(t *testing.T, msg string) {
|
||||
result := Parse(msg)
|
||||
if result < TypeNone || result > TypeBreaking {
|
||||
t.Errorf("Parse(%q) returned out-of-range type %d", msg, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTypeString(t *testing.T) {
|
||||
cases := []struct {
|
||||
t Type
|
||||
want string
|
||||
}{
|
||||
{TypeNone, "none"},
|
||||
{TypeFix, "fix"},
|
||||
{TypeFeat, "feat"},
|
||||
{TypeBreaking, "breaking"},
|
||||
{Type(99), "none"}, // unknown → default
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.t.String(); got != c.want {
|
||||
t.Errorf("Type(%d).String() = %q, want %q", c.t, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
cases := []struct {
|
||||
msg string
|
||||
want Type
|
||||
}{
|
||||
// standard cases
|
||||
{"feat: add login page", TypeFeat},
|
||||
{"fix: correct null pointer", TypeFix},
|
||||
{"chore: update deps", TypeNone},
|
||||
{"docs: update README", TypeNone},
|
||||
|
||||
// non-strict: case-insensitive
|
||||
{"Feat: add login page", TypeFeat},
|
||||
{"FIX: correct null pointer", TypeFix},
|
||||
{"FEAT: add login", TypeFeat},
|
||||
|
||||
// non-strict: missing space after colon
|
||||
{"feat:add login", TypeFeat},
|
||||
{"fix:correct null pointer", TypeFix},
|
||||
|
||||
// with scope
|
||||
{"feat(auth): add OAuth2", TypeFeat},
|
||||
{"fix(db): prevent deadlock", TypeFix},
|
||||
|
||||
// breaking change via !
|
||||
{"feat!: remove legacy API", TypeBreaking},
|
||||
{"fix!: change response format", TypeBreaking},
|
||||
{"feat(api)!: rename endpoint", TypeBreaking},
|
||||
|
||||
// breaking change via footer
|
||||
{"feat: new API\n\nBREAKING CHANGE: old API removed", TypeBreaking},
|
||||
{"refactor: cleanup\n\nBREAKING CHANGE: interface changed", TypeBreaking},
|
||||
|
||||
// unparseable — silently ignored
|
||||
{"WIP", TypeNone},
|
||||
{"Merge branch 'main' into release/1.2", TypeNone},
|
||||
{"", TypeNone},
|
||||
{"fix a bug without colon", TypeNone},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := Parse(c.msg)
|
||||
if got != c.want {
|
||||
t.Errorf("Parse(%q) = %s, want %s", c.msg, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.k3nny.fr/releaser/internal/branch"
|
||||
)
|
||||
|
||||
const filename = ".releaser.yml"
|
||||
|
||||
type Config struct {
|
||||
Git GitConfig `yaml:"git"`
|
||||
Maven MavenConfig `yaml:"maven"`
|
||||
GitLab GitLabConfig `yaml:"gitlab"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type MavenConfig struct {
|
||||
PomPath string `yaml:"pom_path"`
|
||||
}
|
||||
|
||||
type GitLabConfig struct {
|
||||
URL string `yaml:"url"`
|
||||
Token string `yaml:"token"`
|
||||
Project string `yaml:"project"`
|
||||
}
|
||||
|
||||
func defaults() Config {
|
||||
return Config{
|
||||
Git: GitConfig{
|
||||
TagPrefix: "v",
|
||||
BranchPattern: branch.DefaultBranchPattern,
|
||||
CommitMessage: "chore(release): {version} [skip ci]",
|
||||
},
|
||||
Maven: MavenConfig{
|
||||
PomPath: "pom.xml",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads .releaser.yml from dir and merges it over the defaults.
|
||||
// Missing file is not an error — defaults are returned as-is.
|
||||
func Load(dir string) (Config, error) {
|
||||
cfg := defaults()
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, filename))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return cfg, nil
|
||||
}
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("read %s: %w", filename, err)
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return cfg, fmt.Errorf("parse %s: %w", filename, err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ApplyEnv fills empty GitLab fields from the standard GitLab CI environment variables.
|
||||
// Values already set in the config file are never overwritten.
|
||||
func (c *Config) ApplyEnv() {
|
||||
if c.GitLab.Token == "" {
|
||||
c.GitLab.Token = os.Getenv("GITLAB_TOKEN")
|
||||
}
|
||||
if c.GitLab.URL == "" {
|
||||
// CI_SERVER_URL is the cleanest source ("https://gitlab.example.com")
|
||||
c.GitLab.URL = os.Getenv("CI_SERVER_URL")
|
||||
}
|
||||
if c.GitLab.Project == "" {
|
||||
// Prefer numeric ID; fall back to namespace/project path
|
||||
if id := os.Getenv("CI_PROJECT_ID"); id != "" {
|
||||
c.GitLab.Project = id
|
||||
} else {
|
||||
c.GitLab.Project = os.Getenv("CI_PROJECT_PATH")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
cfg, err := Load(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Git.TagPrefix != "v" {
|
||||
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "v")
|
||||
}
|
||||
if cfg.Maven.PomPath != "pom.xml" {
|
||||
t.Errorf("PomPath = %q, want %q", cfg.Maven.PomPath, "pom.xml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := `
|
||||
git:
|
||||
tag_prefix: ""
|
||||
commit_message: "release: {version}"
|
||||
maven:
|
||||
pom_path: "services/api/pom.xml"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Git.TagPrefix != "" {
|
||||
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "")
|
||||
}
|
||||
if cfg.Git.CommitMessage != "release: {version}" {
|
||||
t.Errorf("CommitMessage = %q", cfg.Git.CommitMessage)
|
||||
}
|
||||
if cfg.Maven.PomPath != "services/api/pom.xml" {
|
||||
t.Errorf("PomPath = %q", cfg.Maven.PomPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReadError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create a directory named .releaser.yml so os.ReadFile fails (is a directory)
|
||||
if err := os.Mkdir(filepath.Join(dir, filename), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := Load(dir)
|
||||
if err == nil {
|
||||
t.Error("expected error when .releaser.yml is a directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadInvalidYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, filename), []byte("{[invalid yaml"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := Load(dir)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid YAML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnv(t *testing.T) {
|
||||
t.Setenv("GITLAB_TOKEN", "mytoken")
|
||||
t.Setenv("CI_SERVER_URL", "https://gitlab.example.com")
|
||||
t.Setenv("CI_PROJECT_ID", "42")
|
||||
|
||||
cfg := defaults()
|
||||
cfg.ApplyEnv()
|
||||
|
||||
if cfg.GitLab.Token != "mytoken" {
|
||||
t.Errorf("Token = %q, want mytoken", cfg.GitLab.Token)
|
||||
}
|
||||
if cfg.GitLab.URL != "https://gitlab.example.com" {
|
||||
t.Errorf("URL = %q", cfg.GitLab.URL)
|
||||
}
|
||||
if cfg.GitLab.Project != "42" {
|
||||
t.Errorf("Project = %q, want 42", cfg.GitLab.Project)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvDoesNotOverwrite(t *testing.T) {
|
||||
t.Setenv("GITLAB_TOKEN", "env-token")
|
||||
t.Setenv("CI_SERVER_URL", "https://env.example.com")
|
||||
t.Setenv("CI_PROJECT_ID", "99")
|
||||
|
||||
cfg := defaults()
|
||||
cfg.GitLab.Token = "config-token"
|
||||
cfg.GitLab.URL = "https://config.example.com"
|
||||
cfg.GitLab.Project = "config-project"
|
||||
cfg.ApplyEnv()
|
||||
|
||||
if cfg.GitLab.Token != "config-token" {
|
||||
t.Errorf("Token overwritten: got %q", cfg.GitLab.Token)
|
||||
}
|
||||
if cfg.GitLab.URL != "https://config.example.com" {
|
||||
t.Errorf("URL overwritten: got %q", cfg.GitLab.URL)
|
||||
}
|
||||
if cfg.GitLab.Project != "config-project" {
|
||||
t.Errorf("Project overwritten: got %q", cfg.GitLab.Project)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvProjectPathFallback(t *testing.T) {
|
||||
t.Setenv("CI_PROJECT_ID", "")
|
||||
t.Setenv("CI_PROJECT_PATH", "mygroup/myapp")
|
||||
|
||||
cfg := defaults()
|
||||
cfg.ApplyEnv()
|
||||
|
||||
if cfg.GitLab.Project != "mygroup/myapp" {
|
||||
t.Errorf("Project = %q, want mygroup/myapp (from CI_PROJECT_PATH)", cfg.GitLab.Project)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPartialOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Only override tag_prefix — commit_message should keep its default
|
||||
content := "git:\n tag_prefix: \"\"\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Git.TagPrefix != "" {
|
||||
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "")
|
||||
}
|
||||
if cfg.Git.CommitMessage != defaults().Git.CommitMessage {
|
||||
t.Errorf("CommitMessage = %q, want default", cfg.Git.CommitMessage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package gitutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
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"
|
||||
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
"github.com/go-git/go-git/v5/plumbing/storer"
|
||||
|
||||
"git.k3nny.fr/releaser/internal/branch"
|
||||
)
|
||||
|
||||
// IsWorkingTreeClean returns true when there are no staged or modified tracked files.
|
||||
// Untracked files are ignored so that unreleased work-in-progress does not block a release.
|
||||
func IsWorkingTreeClean(repo *gogit.Repository) (bool, error) {
|
||||
w, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
status, err := w.Status()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, s := range status {
|
||||
if s.Staging != gogit.Unmodified && s.Staging != gogit.Untracked {
|
||||
return false, nil
|
||||
}
|
||||
if s.Worktree != gogit.Unmodified && s.Worktree != gogit.Untracked {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CurrentBranch returns the short branch name from HEAD.
|
||||
// Returns an error when HEAD is detached (common in CI pipelines).
|
||||
func CurrentBranch(repo *gogit.Repository) (string, error) {
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read HEAD: %w", err)
|
||||
}
|
||||
if !head.Name().IsBranch() {
|
||||
return "", fmt.Errorf("HEAD is detached — use --branch to specify the release branch")
|
||||
}
|
||||
return head.Name().Short(), nil
|
||||
}
|
||||
|
||||
type tagCandidate struct {
|
||||
name string
|
||||
patch int
|
||||
}
|
||||
|
||||
// LatestTag returns the highest-patch tag matching the branch's version range that is
|
||||
// reachable from HEAD. Returns ("", -1, nil) when no matching ancestor tag exists.
|
||||
func LatestTag(repo *gogit.Repository, info branch.Info) (string, int, error) {
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return "", -1, err
|
||||
}
|
||||
headCommit, err := repo.CommitObject(head.Hash())
|
||||
if err != nil {
|
||||
return "", -1, err
|
||||
}
|
||||
|
||||
refs, err := repo.Tags()
|
||||
if err != nil {
|
||||
return "", -1, err
|
||||
}
|
||||
|
||||
var candidates []tagCandidate
|
||||
err = refs.ForEach(func(ref *plumbing.Reference) error {
|
||||
name := ref.Name().Short()
|
||||
patch, ok := info.MatchesTag(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
commitHash, err := resolveTagToCommit(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 {
|
||||
return nil
|
||||
}
|
||||
|
||||
candidates = append(candidates, tagCandidate{name, patch})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", -1, err
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return "", -1, nil
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].patch > candidates[j].patch
|
||||
})
|
||||
|
||||
best := candidates[0]
|
||||
return best.name, best.patch, nil
|
||||
}
|
||||
|
||||
// CommitsSince returns commit messages from HEAD down to (but not including) the tagged commit.
|
||||
func CommitsSince(repo *gogit.Repository, tagName string) ([]string, error) {
|
||||
ref, err := repo.Tag(tagName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve tag %q: %w", tagName, err)
|
||||
}
|
||||
|
||||
tagHash, err := resolveTagToCommit(repo, ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve tag commit: %w", err)
|
||||
}
|
||||
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
iter, err := repo.Log(&gogit.LogOptions{From: head.Hash()})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var messages []string
|
||||
err = iter.ForEach(func(c *object.Commit) error {
|
||||
if c.Hash == tagHash {
|
||||
return storer.ErrStop
|
||||
}
|
||||
messages = append(messages, c.Message)
|
||||
return nil
|
||||
})
|
||||
return messages, err
|
||||
}
|
||||
|
||||
// AllCommits returns all commit messages reachable from HEAD.
|
||||
func AllCommits(repo *gogit.Repository) ([]string, error) {
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
iter, err := repo.Log(&gogit.LogOptions{From: head.Hash()})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var messages []string
|
||||
err = iter.ForEach(func(c *object.Commit) error {
|
||||
messages = append(messages, c.Message)
|
||||
return nil
|
||||
})
|
||||
return messages, err
|
||||
}
|
||||
|
||||
// AuthorFromConfig reads user.name and user.email from the repository's git config.
|
||||
// Local config overrides global; returns empty strings if neither is set.
|
||||
func AuthorFromConfig(repo *gogit.Repository) (name, email string) {
|
||||
cfg, err := repo.ConfigScoped(gitconfig.GlobalScope)
|
||||
if err == nil {
|
||||
name = cfg.User.Name
|
||||
email = cfg.User.Email
|
||||
}
|
||||
// Local config overrides global
|
||||
local, err := repo.Config()
|
||||
if err == nil {
|
||||
if local.User.Name != "" {
|
||||
name = local.User.Name
|
||||
}
|
||||
if local.User.Email != "" {
|
||||
email = local.User.Email
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// CommitFile stages filePath (relative to worktree root) and creates a commit.
|
||||
func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEmail string) (plumbing.Hash, error) {
|
||||
w, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return plumbing.ZeroHash, err
|
||||
}
|
||||
|
||||
if _, err := w.Add(filePath); err != nil {
|
||||
return plumbing.ZeroHash, fmt.Errorf("git add %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
hash, err := w.Commit(message, &gogit.CommitOptions{
|
||||
Author: &object.Signature{
|
||||
Name: authorName,
|
||||
Email: authorEmail,
|
||||
When: time.Now(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return plumbing.ZeroHash, fmt.Errorf("git commit: %w", err)
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
// CreateTag creates a lightweight tag on HEAD.
|
||||
func CreateTag(repo *gogit.Repository, tagName string) error {
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := repo.CreateTag(tagName, head.Hash(), nil); err != nil {
|
||||
return fmt.Errorf("create tag %s: %w", tagName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Push pushes the given branch and tag to the "origin" remote.
|
||||
// If token is non-empty, HTTPS basic auth (oauth2/token) is used.
|
||||
// Passing an empty token lets go-git use the system credential helper or SSH agent.
|
||||
func Push(repo *gogit.Repository, branchName, tagName, token string) error {
|
||||
remote, err := repo.Remote("origin")
|
||||
if err != nil {
|
||||
return fmt.Errorf("remote origin not found: %w", err)
|
||||
}
|
||||
|
||||
opts := &gogit.PushOptions{
|
||||
RefSpecs: []gitconfig.RefSpec{
|
||||
gitconfig.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", branchName, branchName)),
|
||||
gitconfig.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName)),
|
||||
},
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
opts.Auth = &githttp.BasicAuth{
|
||||
Username: "oauth2",
|
||||
Password: token,
|
||||
}
|
||||
}
|
||||
|
||||
if err := remote.Push(opts); err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) {
|
||||
return fmt.Errorf("git push: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveTagToCommit follows tag objects until it reaches a commit.
|
||||
// Handles both lightweight tags (ref → commit) and annotated tags (ref → tag object → … → commit).
|
||||
func resolveTagToCommit(repo *gogit.Repository, ref *plumbing.Reference) (plumbing.Hash, error) {
|
||||
hash := ref.Hash()
|
||||
for {
|
||||
obj, err := repo.Object(plumbing.AnyObject, hash)
|
||||
if err != nil {
|
||||
return plumbing.ZeroHash, err
|
||||
}
|
||||
switch o := obj.(type) {
|
||||
case *object.Commit:
|
||||
return o.Hash, nil
|
||||
case *object.Tag:
|
||||
hash = o.Target
|
||||
default:
|
||||
return plumbing.ZeroHash, fmt.Errorf("unexpected object type %s at %s", obj.Type(), hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
package gitutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
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"
|
||||
|
||||
"git.k3nny.fr/releaser/internal/branch"
|
||||
)
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func newTestRepo(t *testing.T) (*gogit.Repository, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
repo, err := gogit.PlainInit(dir, false)
|
||||
if err != nil {
|
||||
t.Fatalf("init repo: %v", err)
|
||||
}
|
||||
return repo, dir
|
||||
}
|
||||
|
||||
func testSig() *object.Signature {
|
||||
return &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()}
|
||||
}
|
||||
|
||||
// addCommit writes content to test.txt, stages it, and creates a commit.
|
||||
func addCommit(t *testing.T, repo *gogit.Repository, dir, message, content string) plumbing.Hash {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, "test.txt"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w, err := repo.Worktree()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Add("test.txt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := w.Commit(message, &gogit.CommitOptions{Author: testSig()})
|
||||
if err != nil {
|
||||
t.Fatalf("commit %q: %v", message, err)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
func addTag(t *testing.T, repo *gogit.Repository, name string) {
|
||||
t.Helper()
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.CreateTag(name, head.Hash(), nil); err != nil {
|
||||
t.Fatalf("create lightweight tag %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func addAnnotatedTag(t *testing.T, repo *gogit.Repository, name string) {
|
||||
t.Helper()
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = repo.CreateTag(name, head.Hash(), &gogit.CreateTagOptions{
|
||||
Tagger: testSig(),
|
||||
Message: "release " + name,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create annotated tag %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── IsWorkingTreeClean ────────────────────────────────────────────────────────
|
||||
|
||||
func TestIsWorkingTreeClean(t *testing.T) {
|
||||
t.Run("clean after commit", func(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "chore: init", "initial")
|
||||
|
||||
clean, err := IsWorkingTreeClean(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !clean {
|
||||
t.Error("expected clean working tree after commit")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dirty when tracked file is modified", func(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "chore: init", "initial")
|
||||
|
||||
os.WriteFile(filepath.Join(dir, "test.txt"), []byte("modified"), 0644)
|
||||
|
||||
clean, err := IsWorkingTreeClean(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if clean {
|
||||
t.Error("expected dirty working tree after modification")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dirty when new file is staged", func(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "chore: init", "initial")
|
||||
|
||||
os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("new"), 0644)
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("staged.txt")
|
||||
|
||||
clean, err := IsWorkingTreeClean(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if clean {
|
||||
t.Error("expected dirty working tree with staged file")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("untracked file does not dirty the tree", func(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "chore: init", "initial")
|
||||
os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("new"), 0644)
|
||||
|
||||
clean, err := IsWorkingTreeClean(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !clean {
|
||||
t.Error("untracked file should not dirty the working tree")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── CurrentBranch ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCurrentBranch(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "chore: init", "initial")
|
||||
|
||||
name, err := CurrentBranch(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name == "" {
|
||||
t.Error("branch name should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentBranchDetached(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "chore: init", "initial")
|
||||
|
||||
// Detach HEAD by replacing the symbolic ref with a hash ref
|
||||
head, _ := repo.Head()
|
||||
detached := plumbing.NewHashReference(plumbing.HEAD, head.Hash())
|
||||
if err := repo.Storer.SetReference(detached); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CurrentBranch(repo)
|
||||
if err == nil {
|
||||
t.Error("expected error for detached HEAD")
|
||||
}
|
||||
}
|
||||
|
||||
// ── LatestTag ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestLatestTagNoTags(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: first", "v1")
|
||||
|
||||
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
|
||||
tag, patch, err := LatestTag(repo, info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tag != "" || patch != -1 {
|
||||
t.Errorf("expected no tag: got %q patch=%d", tag, patch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestTagSingle(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: first", "v1")
|
||||
addTag(t, repo, "v1.2.0")
|
||||
addCommit(t, repo, dir, "fix: second", "v2")
|
||||
|
||||
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
|
||||
tag, patch, err := LatestTag(repo, info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tag != "v1.2.0" || patch != 0 {
|
||||
t.Errorf("got tag=%q patch=%d, want v1.2.0 patch=0", tag, patch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestTagPicksHighestPatch(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")
|
||||
addTag(t, repo, "v1.2.1")
|
||||
addCommit(t, repo, dir, "feat: c3", "v3")
|
||||
addTag(t, repo, "v1.2.5")
|
||||
addCommit(t, repo, dir, "fix: c4", "v4") // HEAD after the last tag
|
||||
|
||||
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
|
||||
tag, patch, err := LatestTag(repo, info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tag != "v1.2.5" || patch != 5 {
|
||||
t.Errorf("got tag=%q patch=%d, want v1.2.5 patch=5", tag, patch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestTagCrossVersionIgnored(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
addTag(t, repo, "v1.2.0") // matching
|
||||
addCommit(t, repo, dir, "fix: c2", "v2")
|
||||
addTag(t, repo, "v1.3.0") // different minor — must be ignored
|
||||
addCommit(t, repo, dir, "fix: c3", "v3")
|
||||
|
||||
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
|
||||
tag, patch, err := LatestTag(repo, info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tag != "v1.2.0" || patch != 0 {
|
||||
t.Errorf("got tag=%q patch=%d, want v1.2.0 patch=0 (v1.3.0 must be ignored)", tag, patch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestTagNoPrefixVariant(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
addTag(t, repo, "2.0.0") // no "v" prefix
|
||||
addCommit(t, repo, dir, "fix: c2", "v2")
|
||||
|
||||
info := branch.Info{Major: 2, Minor: 0, TagPrefix: ""}
|
||||
tag, patch, err := LatestTag(repo, info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tag != "2.0.0" || patch != 0 {
|
||||
t.Errorf("got tag=%q patch=%d, want 2.0.0 patch=0", tag, patch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestTagAnnotated(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
addAnnotatedTag(t, repo, "v1.2.0")
|
||||
addCommit(t, repo, dir, "fix: c2", "v2")
|
||||
|
||||
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
|
||||
tag, patch, err := LatestTag(repo, info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tag != "v1.2.0" || patch != 0 {
|
||||
t.Errorf("annotated tag: got %q patch=%d, want v1.2.0", tag, patch)
|
||||
}
|
||||
}
|
||||
|
||||
// ── CommitsSince ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCommitsSince(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "chore: baseline", "v0")
|
||||
addTag(t, repo, "v1.2.0")
|
||||
addCommit(t, repo, dir, "fix: bug fix", "v1")
|
||||
addCommit(t, repo, dir, "feat: new feature", "v2")
|
||||
|
||||
msgs, err := CommitsSince(repo, "v1.2.0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("expected 2 commits since v1.2.0, got %d: %v", len(msgs), msgs)
|
||||
}
|
||||
// Log is reverse-chronological: newest first
|
||||
if !strings.Contains(msgs[0], "feat: new feature") {
|
||||
t.Errorf("msgs[0] = %q, want feat: new feature", msgs[0])
|
||||
}
|
||||
if !strings.Contains(msgs[1], "fix: bug fix") {
|
||||
t.Errorf("msgs[1] = %q, want fix: bug fix", msgs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitsSinceTagOnHead(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: something", "v1")
|
||||
addTag(t, repo, "v1.2.0")
|
||||
|
||||
// HEAD IS the tag — no commits since
|
||||
msgs, err := CommitsSince(repo, "v1.2.0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(msgs) != 0 {
|
||||
t.Errorf("expected 0 commits since tag on HEAD, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
// ── AllCommits ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAllCommits(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
addCommit(t, repo, dir, "fix: c2", "v2")
|
||||
addCommit(t, repo, dir, "fix: c3", "v3")
|
||||
|
||||
msgs, err := AllCommits(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(msgs) != 3 {
|
||||
t.Errorf("expected 3 commits, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
// ── CommitFile ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCommitFile(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "chore: initial", "init")
|
||||
|
||||
pomPath := filepath.Join(dir, "pom.xml")
|
||||
if err := os.WriteFile(pomPath, []byte("<project><version>1.2.4</version></project>"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hash, err := CommitFile(repo, "pom.xml", "chore(release): v1.2.4 [skip ci]", "Releaser", "ci@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hash.IsZero() {
|
||||
t.Error("expected non-zero commit hash")
|
||||
}
|
||||
|
||||
commit, err := repo.CommitObject(hash)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if commit.Message != "chore(release): v1.2.4 [skip ci]" {
|
||||
t.Errorf("commit message = %q", commit.Message)
|
||||
}
|
||||
if commit.Author.Name != "Releaser" {
|
||||
t.Errorf("author name = %q, want Releaser", commit.Author.Name)
|
||||
}
|
||||
if commit.Author.Email != "ci@example.com" {
|
||||
t.Errorf("author email = %q", commit.Author.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// ── CreateTag ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCreateTag(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: something", "v1")
|
||||
|
||||
if err := CreateTag(repo, "v1.2.0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ref, err := repo.Tag("v1.2.0")
|
||||
if err != nil {
|
||||
t.Fatalf("tag v1.2.0 not found after creation: %v", err)
|
||||
}
|
||||
head, _ := repo.Head()
|
||||
if ref.Hash() != head.Hash() {
|
||||
t.Error("lightweight tag does not point to HEAD")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTagDuplicate(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: something", "v1")
|
||||
addTag(t, repo, "v1.2.0")
|
||||
|
||||
if err := CreateTag(repo, "v1.2.0"); err == nil {
|
||||
t.Error("expected error when creating duplicate tag")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Push (error path only — no real remote needed) ───────────────────────────
|
||||
|
||||
func TestPushRemoteFails(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: something", "v1")
|
||||
addTag(t, repo, "v1.2.0")
|
||||
|
||||
// Remote config exists but points nowhere → Push will error on remote.Push
|
||||
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{"/nonexistent/bare/repo"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := Push(repo, "master", "v1.2.0", "")
|
||||
if err == nil {
|
||||
t.Error("expected push error for invalid remote path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushNoRemote(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: something", "v1")
|
||||
|
||||
err := Push(repo, "master", "v1.0.0", "")
|
||||
if err == nil {
|
||||
t.Error("expected error when no remote is configured")
|
||||
}
|
||||
}
|
||||
|
||||
// ── bare-repo error paths ─────────────────────────────────────────────────────
|
||||
|
||||
func TestIsWorkingTreeCleanBareRepo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo, err := gogit.PlainInit(dir, true) // bare = true
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = IsWorkingTreeClean(repo)
|
||||
if err == nil {
|
||||
t.Error("expected error for bare repo (no worktree)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentBranchEmptyRepo(t *testing.T) {
|
||||
// Fresh repo with no commits — HEAD symbolic ref points to refs/heads/master
|
||||
// but that ref doesn't exist yet, so Head() returns plumbing.ErrReferenceNotFound.
|
||||
repo, _ := newTestRepo(t)
|
||||
_, err := CurrentBranch(repo)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty repo (no HEAD commit)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllCommitsEmptyRepo(t *testing.T) {
|
||||
repo, _ := newTestRepo(t)
|
||||
_, err := AllCommits(repo)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty repo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTagEmptyRepo(t *testing.T) {
|
||||
repo, _ := newTestRepo(t)
|
||||
err := CreateTag(repo, "v1.0.0")
|
||||
if err == nil {
|
||||
t.Error("expected error when creating tag on empty repo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestTagEmptyRepo(t *testing.T) {
|
||||
repo, _ := newTestRepo(t)
|
||||
_, _, err := LatestTag(repo, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"})
|
||||
if err == nil {
|
||||
t.Error("expected error for empty repo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitFileBareRepo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo, err := gogit.PlainInit(dir, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = CommitFile(repo, "pom.xml", "chore: test", "Test", "t@t.com")
|
||||
if err == nil {
|
||||
t.Error("expected error for bare repo (no worktree)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitFileNonexistentFile(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "chore: init", "init")
|
||||
|
||||
// Try to stage a file that doesn't exist in the worktree
|
||||
_, err := CommitFile(repo, "nonexistent.xml", "chore: bad", "Test", "t@t.com")
|
||||
if err == nil {
|
||||
t.Error("expected error when staging nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
// ── LatestTag edge cases ──────────────────────────────────────────────────────
|
||||
|
||||
func TestLatestTagOnHead(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
addTag(t, repo, "v1.2.0") // tag is ON HEAD, not behind HEAD
|
||||
|
||||
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
|
||||
tag, patch, err := LatestTag(repo, info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Tag at HEAD should still be found (it is the current release base)
|
||||
if tag != "v1.2.0" || patch != 0 {
|
||||
t.Errorf("got %q patch=%d, want v1.2.0 patch=0 when tag is on HEAD", tag, patch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestTagNonAncestorIgnored(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
baseHash := addCommit(t, repo, dir, "chore: base", "base")
|
||||
|
||||
// Create sibling branch from base commit and put a v1.2.0 tag on it
|
||||
w, _ := repo.Worktree()
|
||||
if err := w.Checkout(&gogit.CheckoutOptions{
|
||||
Branch: plumbing.NewBranchReferenceName("sibling"),
|
||||
Hash: baseHash,
|
||||
Create: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addCommit(t, repo, dir, "feat: sibling work", "sibling")
|
||||
addTag(t, repo, "v1.2.0")
|
||||
|
||||
// Switch back to master and add a commit (diverges from sibling)
|
||||
if err := w.Checkout(&gogit.CheckoutOptions{
|
||||
Branch: plumbing.NewBranchReferenceName("master"),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addCommit(t, repo, dir, "fix: mainline only", "mainline")
|
||||
|
||||
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
|
||||
tag, patch, err := LatestTag(repo, info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// v1.2.0 is on the sibling branch — not an ancestor of current HEAD
|
||||
if tag != "" || patch != -1 {
|
||||
t.Errorf("non-ancestor tag should be ignored: got %q patch=%d", tag, patch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestTagSkipsMalformedRef(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
|
||||
// Create a v1.2.0 tag reference pointing to a hash that doesn't exist.
|
||||
// LatestTag must silently skip it instead of returning an error.
|
||||
fakeHash := plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
|
||||
fakeRef := plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.2.0"), fakeHash)
|
||||
if err := repo.Storer.SetReference(fakeRef); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
|
||||
tag, patch, err := LatestTag(repo, info)
|
||||
if err != nil {
|
||||
t.Fatalf("LatestTag should not error on malformed tag ref: %v", err)
|
||||
}
|
||||
if tag != "" || patch != -1 {
|
||||
t.Errorf("malformed tag should be skipped: got %q patch=%d", tag, patch)
|
||||
}
|
||||
}
|
||||
|
||||
// ── resolveTagToCommit default case ──────────────────────────────────────────
|
||||
|
||||
func TestResolveTagToCommitBlobRef(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
h := addCommit(t, repo, dir, "fix: c1", "content")
|
||||
|
||||
// Obtain a blob hash from the commit tree to use as an adversarial ref target.
|
||||
commit, err := repo.CommitObject(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tree, err := commit.Tree()
|
||||
if err != nil || len(tree.Entries) == 0 {
|
||||
t.Fatal("need at least one tree entry")
|
||||
}
|
||||
blobHash := tree.Entries[0].Hash
|
||||
|
||||
blobRef := plumbing.NewHashReference(plumbing.NewTagReferenceName("blob-tag"), blobHash)
|
||||
_, err = resolveTagToCommit(repo, blobRef)
|
||||
if err == nil {
|
||||
t.Error("expected error when tag points to a blob (not a commit or tag object)")
|
||||
}
|
||||
}
|
||||
|
||||
// ── CommitsSince error paths ──────────────────────────────────────────────────
|
||||
|
||||
func TestCommitsSinceBadTag(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: something", "v1")
|
||||
|
||||
_, err := CommitsSince(repo, "v-does-not-exist")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent tag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitsSinceMalformedTagRef(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
|
||||
// Create a tag that references a nonexistent hash so resolveTagToCommit fails.
|
||||
fakeHash := plumbing.NewHash("cafebabecafebabecafebabecafebabecafebabe")
|
||||
fakeRef := plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.2.0"), fakeHash)
|
||||
if err := repo.Storer.SetReference(fakeRef); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CommitsSince(repo, "v1.2.0")
|
||||
if err == nil {
|
||||
t.Error("expected error when resolveTagToCommit fails on a malformed tag ref")
|
||||
}
|
||||
}
|
||||
|
||||
// ── AuthorFromConfig ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestAuthorFromConfigDoesNotPanic(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "chore: init", "init")
|
||||
|
||||
// May return empty strings in CI where ~/.gitconfig has no user section — must not panic
|
||||
name, email := AuthorFromConfig(repo)
|
||||
_ = name
|
||||
_ = email
|
||||
}
|
||||
|
||||
func TestAuthorFromConfigLocalOverride(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "chore: init", "init")
|
||||
|
||||
// Write a local git config with user.name / user.email
|
||||
localCfg := `[user]
|
||||
name = LocalUser
|
||||
email = local@example.com
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, ".git", "config"), []byte(localCfg), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Reload repo so it picks up the config file we just wrote
|
||||
repo2, err := gogit.PlainOpen(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
name, email := AuthorFromConfig(repo2)
|
||||
if name != "LocalUser" {
|
||||
t.Errorf("name = %q, want LocalUser", name)
|
||||
}
|
||||
if email != "local@example.com" {
|
||||
t.Errorf("email = %q, want local@example.com", email)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Push with a real bare remote ──────────────────────────────────────────────
|
||||
|
||||
func TestPushWithBareRemote(t *testing.T) {
|
||||
// Create a bare repo to act as the remote
|
||||
remoteDir := t.TempDir()
|
||||
if _, err := gogit.PlainInit(remoteDir, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create the working repo
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: something", "v1")
|
||||
addTag(t, repo, "v1.2.0")
|
||||
|
||||
// Wire the bare repo as origin
|
||||
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{remoteDir},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Push with a token (exercises the auth branch even though local transport ignores it)
|
||||
err := Push(repo, "master", "v1.2.0", "dummy-token")
|
||||
if err != nil {
|
||||
t.Fatalf("Push to bare remote failed: %v", err)
|
||||
}
|
||||
|
||||
// Push again — should hit the NoErrAlreadyUpToDate branch and return nil
|
||||
err = Push(repo, "master", "v1.2.0", "dummy-token")
|
||||
if err != nil {
|
||||
t.Fatalf("second Push returned unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package glclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is a minimal GitLab API client covering only the Releases endpoint.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
projectID string // numeric ID or URL-encoded namespace/project
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New creates a Client. baseURL is the GitLab instance root (e.g. "https://gitlab.example.com").
|
||||
// project can be a numeric ID ("123") or a namespace/project path ("group/myapp").
|
||||
func New(baseURL, token, project string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
projectID: encodeProjectPath(project),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// endpoint builds a full API URL for the given resource under the project.
|
||||
// Uses url.URL{Path, RawPath} to preserve %2F encoding in the project path —
|
||||
// url.Parse would silently decode %2F back to / in the Path field.
|
||||
func (c *Client) endpoint(resource string) string {
|
||||
rawPath := fmt.Sprintf("/api/v4/projects/%s/%s", c.projectID, resource)
|
||||
u, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return c.baseURL + rawPath // fallback, handles only simple cases
|
||||
}
|
||||
u.Path = strings.ReplaceAll(rawPath, "%2F", "/")
|
||||
u.RawPath = rawPath
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// encodeProjectPath URL-encodes a GitLab project identifier for use in an API path.
|
||||
// Numeric IDs ("123") pass through unchanged.
|
||||
// Path-style IDs ("group/project") have each segment encoded and slashes replaced with %2F,
|
||||
// because url.PathEscape does not encode / (it is a valid path separator).
|
||||
func encodeProjectPath(project string) string {
|
||||
parts := strings.Split(project, "/")
|
||||
for i, p := range parts {
|
||||
parts[i] = url.PathEscape(p)
|
||||
}
|
||||
return strings.Join(parts, "%2F")
|
||||
}
|
||||
|
||||
type createReleaseRequest struct {
|
||||
Name string `json:"name"`
|
||||
TagName string `json:"tag_name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// CreateRelease creates a GitLab release on an existing tag.
|
||||
// The tag must already be pushed to the remote before calling this.
|
||||
func (c *Client) CreateRelease(ctx context.Context, tagName, description string) error {
|
||||
// json.Marshal never fails for a struct with only string fields
|
||||
body, _ := json.Marshal(createReleaseRequest{
|
||||
Name: tagName,
|
||||
TagName: tagName,
|
||||
Description: description,
|
||||
})
|
||||
|
||||
endpoint := c.endpoint("releases")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("PRIVATE-TOKEN", c.token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GitLab API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
var errBody struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&errBody) //nolint:errcheck
|
||||
if errBody.Message != "" {
|
||||
return fmt.Errorf("GitLab API returned %d: %s", resp.StatusCode, errBody.Message)
|
||||
}
|
||||
return fmt.Errorf("GitLab API returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package glclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateRelease(t *testing.T) {
|
||||
var received createReleaseRequest
|
||||
var gotToken string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", r.Method)
|
||||
}
|
||||
gotToken = r.Header.Get("PRIVATE-TOKEN")
|
||||
json.NewDecoder(r.Body).Decode(&received) //nolint:errcheck
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(`{}`)) //nolint:errcheck
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := New(srv.URL, "my-token", "123")
|
||||
err := client.CreateRelease(context.Background(), "v1.2.4", "## v1.2.4\n\n### Bug Fixes\n- patch something")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if gotToken != "my-token" {
|
||||
t.Errorf("PRIVATE-TOKEN = %q, want %q", gotToken, "my-token")
|
||||
}
|
||||
if received.TagName != "v1.2.4" {
|
||||
t.Errorf("tag_name = %q, want %q", received.TagName, "v1.2.4")
|
||||
}
|
||||
if received.Name != "v1.2.4" {
|
||||
t.Errorf("name = %q, want %q", received.Name, "v1.2.4")
|
||||
}
|
||||
if received.Description == "" {
|
||||
t.Error("description should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateReleaseAPIError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
w.Write([]byte(`{"message":"Tag already has a release"}`)) //nolint:errcheck
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := New(srv.URL, "token", "42")
|
||||
err := client.CreateRelease(context.Background(), "v1.2.4", "notes")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 422 response")
|
||||
}
|
||||
if err.Error() == "" {
|
||||
t.Error("error message should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPathEncoding(t *testing.T) {
|
||||
var gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// EscapedPath returns RawPath when set, preserving %2F encoding.
|
||||
// r.URL.Path is always decoded so we must not use it here.
|
||||
gotPath = r.URL.EscapedPath()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte(`{}`)) //nolint:errcheck
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := New(srv.URL, "token", "group/my-app")
|
||||
client.CreateRelease(context.Background(), "v1.0.0", "") //nolint:errcheck
|
||||
|
||||
// "group/my-app" must be URL-encoded as "group%2Fmy-app"
|
||||
if gotPath != "/api/v4/projects/group%2Fmy-app/releases" {
|
||||
t.Errorf("path = %q, want /api/v4/projects/group%%2Fmy-app/releases", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointFallback(t *testing.T) {
|
||||
// "://" is an invalid URL that url.Parse will reject,
|
||||
// exercising the fallback branch in endpoint().
|
||||
c := New("://invalid", "token", "123")
|
||||
got := c.endpoint("releases")
|
||||
want := "://invalid/api/v4/projects/123/releases"
|
||||
if got != want {
|
||||
t.Errorf("endpoint fallback = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateReleaseStatusNoMessage(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`not json`)) //nolint:errcheck
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := New(srv.URL, "token", "42").CreateRelease(context.Background(), "v1.0.0", "notes")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 500 response")
|
||||
}
|
||||
// Should contain just the status code, no message field
|
||||
if !strings.Contains(err.Error(), "500") {
|
||||
t.Errorf("error should mention status 500: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateReleaseInvalidURL(t *testing.T) {
|
||||
// "://" is unparseable as a URL scheme, so endpoint() returns the raw fallback
|
||||
// string and http.NewRequestWithContext fails when it tries to parse it.
|
||||
c := New("://", "token", "42")
|
||||
err := c.CreateRelease(context.Background(), "v1.0.0", "notes")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for client with invalid base URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateReleaseNetworkError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
srv.Close() // close immediately so the request fails at TCP level
|
||||
|
||||
err := New(srv.URL, "token", "42").CreateRelease(context.Background(), "v1.0.0", "notes")
|
||||
if err == nil {
|
||||
t.Fatal("expected network error after server is closed")
|
||||
}
|
||||
}
|
||||
|
||||
// FuzzEncodeProjectPath verifies that the encoder never panics and never emits
|
||||
// a raw slash when the input contains one.
|
||||
func FuzzEncodeProjectPath(f *testing.F) {
|
||||
f.Add("group/project")
|
||||
f.Add("123")
|
||||
f.Add("")
|
||||
f.Add("group/subgroup/project")
|
||||
f.Add("a/b/c/d")
|
||||
f.Add("/leading/slash")
|
||||
f.Add("trailing/slash/")
|
||||
f.Add(strings.Repeat("a/", 50))
|
||||
|
||||
f.Fuzz(func(t *testing.T, project string) {
|
||||
result := encodeProjectPath(project)
|
||||
// A slash in the input must NOT appear as a literal slash in the result
|
||||
if strings.Contains(project, "/") && strings.Contains(result, "/") {
|
||||
t.Errorf("encodeProjectPath(%q) = %q: contains unencoded slash", project, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package maven
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var parentBlockRe = regexp.MustCompile(`(?s)<parent>.*?</parent>`)
|
||||
var versionTagRe = regexp.MustCompile(`<version>([^<]+)</version>`)
|
||||
|
||||
// ReadVersion returns the project version from a pom.xml file.
|
||||
// It ignores the <parent> block version and dependency versions.
|
||||
func ReadVersion(pomPath string) (string, error) {
|
||||
data, err := os.ReadFile(pomPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", pomPath, err)
|
||||
}
|
||||
|
||||
// Strip <parent>…</parent> before searching so we don't pick up the parent version.
|
||||
stripped := parentBlockRe.ReplaceAllString(string(data), "")
|
||||
|
||||
m := versionTagRe.FindStringSubmatch(stripped)
|
||||
if m == nil {
|
||||
return "", fmt.Errorf("no <version> element found in %s", pomPath)
|
||||
}
|
||||
return strings.TrimSpace(m[1]), nil
|
||||
}
|
||||
|
||||
// WriteVersion replaces the project version in a pom.xml file in-place.
|
||||
// oldVersion must match what ReadVersion returned. The <parent> version is never touched.
|
||||
func WriteVersion(pomPath, oldVersion, newVersion string) error {
|
||||
data, err := os.ReadFile(pomPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", pomPath, err)
|
||||
}
|
||||
|
||||
updated := replaceProjectVersion(string(data), oldVersion, newVersion)
|
||||
if updated == string(data) {
|
||||
return fmt.Errorf("version %q not found in %s", oldVersion, pomPath)
|
||||
}
|
||||
|
||||
return os.WriteFile(pomPath, []byte(updated), 0644)
|
||||
}
|
||||
|
||||
// replaceProjectVersion replaces the first <version>oldVersion</version> that is not
|
||||
// inside a <parent> block. This preserves the parent version and dependency versions.
|
||||
func replaceProjectVersion(content, oldVersion, newVersion string) string {
|
||||
target := "<version>" + oldVersion + "</version>"
|
||||
replacement := "<version>" + newVersion + "</version>"
|
||||
|
||||
// If there is a <parent> block, start searching after it.
|
||||
if idx := strings.Index(content, "</parent>"); idx >= 0 {
|
||||
after := content[idx:]
|
||||
if pos := strings.Index(after, target); pos >= 0 {
|
||||
abs := idx + pos
|
||||
return content[:abs] + replacement + content[abs+len(target):]
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
return strings.Replace(content, target, replacement, 1)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package maven
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const simplePom = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>myapp</artifactId>
|
||||
<version>1.2.3</version>
|
||||
<name>My App</name>
|
||||
</project>`
|
||||
|
||||
const pomWithParent = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.0</version>
|
||||
</parent>
|
||||
<groupId>com.example</groupId>
|
||||
<artifactId>myapp</artifactId>
|
||||
<version>1.2.3</version>
|
||||
</project>`
|
||||
|
||||
const pomWithSnapshot = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project>
|
||||
<artifactId>myapp</artifactId>
|
||||
<version>1.2.4-SNAPSHOT</version>
|
||||
</project>`
|
||||
|
||||
func writePom(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "pom.xml")
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestReadVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{"simple pom", simplePom, "1.2.3"},
|
||||
{"pom with parent", pomWithParent, "1.2.3"},
|
||||
{"snapshot version", pomWithSnapshot, "1.2.4-SNAPSHOT"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := ReadVersion(writePom(t, c.content))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("got %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadVersionParentNotMatched(t *testing.T) {
|
||||
// ReadVersion must return the project version, NOT the parent version (3.2.0).
|
||||
got, err := ReadVersion(writePom(t, pomWithParent))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "1.2.3" {
|
||||
t.Errorf("got %q, want 1.2.3 (parent version must be ignored)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
oldVersion string
|
||||
newVersion string
|
||||
wantInFile string
|
||||
}{
|
||||
{"simple replace", simplePom, "1.2.3", "1.2.4", "<version>1.2.4</version>"},
|
||||
{"with parent", pomWithParent, "1.2.3", "1.2.4", "<version>1.2.4</version>"},
|
||||
{"snapshot to release", pomWithSnapshot, "1.2.4-SNAPSHOT", "1.2.4", "<version>1.2.4</version>"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
path := writePom(t, c.content)
|
||||
if err := WriteVersion(path, c.oldVersion, c.newVersion); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
content := string(data)
|
||||
|
||||
if !contains(content, c.wantInFile) {
|
||||
t.Errorf("expected %q in updated pom, got:\n%s", c.wantInFile, content)
|
||||
}
|
||||
|
||||
// Parent version must be unchanged in the parent block case.
|
||||
if c.name == "with parent" && !contains(content, "<version>3.2.0</version>") {
|
||||
t.Error("parent version was modified")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteVersionParentPreserved(t *testing.T) {
|
||||
path := writePom(t, pomWithParent)
|
||||
if err := WriteVersion(path, "1.2.3", "1.2.4"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := ReadVersion(path)
|
||||
if got != "1.2.4" {
|
||||
t.Errorf("project version = %q, want 1.2.4", got)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
if !contains(string(data), "<version>3.2.0</version>") {
|
||||
t.Error("parent version was modified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadVersionMissingFile(t *testing.T) {
|
||||
_, err := ReadVersion(filepath.Join(t.TempDir(), "nonexistent.xml"))
|
||||
if err == nil {
|
||||
t.Error("expected error for missing pom file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadVersionNoVersionTag(t *testing.T) {
|
||||
_, err := ReadVersion(writePom(t, "<project><artifactId>app</artifactId></project>"))
|
||||
if err == nil {
|
||||
t.Error("expected error when no <version> tag present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteVersionMissingFile(t *testing.T) {
|
||||
err := WriteVersion(filepath.Join(t.TempDir(), "nosuchfile.xml"), "1.0", "1.1")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing pom file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteVersionNotFound(t *testing.T) {
|
||||
err := WriteVersion(writePom(t, simplePom), "9.9.9", "9.9.10")
|
||||
if err == nil {
|
||||
t.Error("expected error when old version not found in pom")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceVersionParentNoMatch(t *testing.T) {
|
||||
// Has </parent> but the target version only appears inside the parent block,
|
||||
// not after it. replaceProjectVersion should return content unchanged.
|
||||
content := `<project><parent><version>3.0.0</version></parent></project>`
|
||||
result := replaceProjectVersion(content, "3.0.0", "4.0.0")
|
||||
if result != content {
|
||||
t.Errorf("expected content unchanged; got:\n%s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool { return strings.Contains(s, sub) }
|
||||
|
||||
// FuzzReadVersion verifies that ReadVersion never panics on arbitrary file content.
|
||||
func FuzzReadVersion(f *testing.F) {
|
||||
f.Add(simplePom)
|
||||
f.Add(pomWithParent)
|
||||
f.Add(pomWithSnapshot)
|
||||
f.Add("")
|
||||
f.Add("<project></project>")
|
||||
f.Add("<version>1.0</version>")
|
||||
f.Add("\x00\x01\xff")
|
||||
|
||||
f.Fuzz(func(t *testing.T, content string) {
|
||||
path := filepath.Join(t.TempDir(), "pom.xml")
|
||||
os.WriteFile(path, []byte(content), 0644) //nolint:errcheck
|
||||
// Must not panic regardless of content
|
||||
ReadVersion(path) //nolint:errcheck
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzReplaceProjectVersion verifies the internal replacer is robust against arbitrary inputs.
|
||||
func FuzzReplaceProjectVersion(f *testing.F) {
|
||||
f.Add(simplePom, "1.2.3", "1.2.4")
|
||||
f.Add(pomWithParent, "1.2.3", "2.0.0")
|
||||
f.Add("", "1.0", "2.0")
|
||||
f.Add("<version>1.0</version>", "1.0", "1.1")
|
||||
f.Add("\x00", "", "1.0")
|
||||
|
||||
f.Fuzz(func(t *testing.T, content, oldVersion, newVersion string) {
|
||||
result := replaceProjectVersion(content, oldVersion, newVersion)
|
||||
// Result must always be a string (no panic)
|
||||
// If a replacement happened, oldVersion must not appear where newVersion was inserted
|
||||
if oldVersion == newVersion {
|
||||
return
|
||||
}
|
||||
if result != content {
|
||||
// Replacement occurred — newVersion must appear in result
|
||||
if !strings.Contains(result, "<version>"+newVersion+"</version>") {
|
||||
t.Errorf("replacement happened but newVersion not found in result")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package notes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.k3nny.fr/releaser/internal/commits"
|
||||
)
|
||||
|
||||
// headerSubjectRe captures the subject (description) part of a conventional commit header.
|
||||
var headerSubjectRe = regexp.MustCompile(`(?i)^\w+(?:\([^)]*\))?!?\s*:\s*(.+)`)
|
||||
|
||||
// Generate produces grouped markdown release notes from a list of commit messages.
|
||||
// Commits are grouped into Breaking Changes, Features, and Bug Fixes.
|
||||
// Commits with no releasable type are omitted.
|
||||
func Generate(tagName string, messages []string) string {
|
||||
var breaking, feats, fixes []string
|
||||
|
||||
for _, msg := range messages {
|
||||
t := commits.Parse(msg)
|
||||
if t == commits.TypeNone {
|
||||
continue
|
||||
}
|
||||
first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0]
|
||||
subject := extractSubject(first)
|
||||
|
||||
switch t {
|
||||
case commits.TypeBreaking:
|
||||
breaking = append(breaking, subject)
|
||||
case commits.TypeFeat:
|
||||
feats = append(feats, subject)
|
||||
case commits.TypeFix:
|
||||
fixes = append(fixes, subject)
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "## %s\n", tagName)
|
||||
writeSection(&sb, "Breaking Changes", breaking)
|
||||
writeSection(&sb, "Features", feats)
|
||||
writeSection(&sb, "Bug Fixes", fixes)
|
||||
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
func writeSection(sb *strings.Builder, title string, items []string) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(sb, "\n### %s\n", title)
|
||||
for _, item := range items {
|
||||
fmt.Fprintf(sb, "- %s\n", item)
|
||||
}
|
||||
}
|
||||
|
||||
// extractSubject returns the description part of a conventional commit header.
|
||||
// Falls back to the raw header if the pattern does not match.
|
||||
func extractSubject(header string) string {
|
||||
m := headerSubjectRe.FindStringSubmatch(header)
|
||||
if m != nil {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
return strings.TrimSpace(header)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package notes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerate(t *testing.T) {
|
||||
messages := []string{
|
||||
"feat: add OAuth2 login",
|
||||
"fix: correct null pointer in user service",
|
||||
"feat(search): improve performance",
|
||||
"chore: update dependencies", // ignored
|
||||
"Merge branch 'feature/x' into main", // ignored
|
||||
"feat!: remove legacy API",
|
||||
"fix: handle empty response",
|
||||
"WIP something", // ignored
|
||||
}
|
||||
|
||||
got := Generate("v1.2.4", messages)
|
||||
|
||||
must := []string{
|
||||
"## v1.2.4",
|
||||
"### Breaking Changes",
|
||||
"- remove legacy API",
|
||||
"### Features",
|
||||
"- add OAuth2 login",
|
||||
"- improve performance",
|
||||
"### Bug Fixes",
|
||||
"- correct null pointer in user service",
|
||||
"- handle empty response",
|
||||
}
|
||||
for _, want := range must {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
absent := []string{"update dependencies", "Merge branch", "WIP"}
|
||||
for _, s := range absent {
|
||||
if strings.Contains(got, s) {
|
||||
t.Errorf("unexpected %q in output:\n%s", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateOnlyFixes(t *testing.T) {
|
||||
messages := []string{
|
||||
"fix: patch SQL injection",
|
||||
"fix: escape HTML output",
|
||||
}
|
||||
got := Generate("v1.2.1", messages)
|
||||
|
||||
if strings.Contains(got, "### Features") {
|
||||
t.Error("Features section should be absent when there are no feat commits")
|
||||
}
|
||||
if strings.Contains(got, "### Breaking Changes") {
|
||||
t.Error("Breaking Changes section should be absent")
|
||||
}
|
||||
if !strings.Contains(got, "### Bug Fixes") {
|
||||
t.Error("Bug Fixes section should be present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateEmpty(t *testing.T) {
|
||||
got := Generate("v1.2.0", []string{"chore: bump deps", "docs: update readme"})
|
||||
if strings.Contains(got, "###") {
|
||||
t.Errorf("no sections expected when no releasable commits, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSubject(t *testing.T) {
|
||||
cases := []struct {
|
||||
header string
|
||||
want string
|
||||
}{
|
||||
{"feat: add login", "add login"},
|
||||
{"feat(auth): add OAuth2", "add OAuth2"},
|
||||
{"feat!: remove API", "remove API"},
|
||||
{"FIX:typo", "typo"},
|
||||
{"plain message", "plain message"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := extractSubject(c.header)
|
||||
if got != c.want {
|
||||
t.Errorf("extractSubject(%q) = %q, want %q", c.header, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FuzzGenerate verifies that Generate never panics on arbitrary inputs and
|
||||
// always includes the tag name in the output.
|
||||
func FuzzGenerate(f *testing.F) {
|
||||
f.Add("v1.2.4", "feat: add thing")
|
||||
f.Add("v1.0.0", "fix: patch")
|
||||
f.Add("1.2.0", "feat!: breaking change")
|
||||
f.Add("v0.0.1", "")
|
||||
f.Add("", "feat: msg")
|
||||
f.Add("v1.0.0", "BREAKING CHANGE: dropped")
|
||||
f.Add("tag\nwith\nnewline", "feat: something")
|
||||
f.Add("v1.0.0", strings.Repeat("feat: x\n", 1000))
|
||||
|
||||
f.Fuzz(func(t *testing.T, tag, msg string) {
|
||||
result := Generate(tag, []string{msg})
|
||||
if !strings.Contains(result, tag) {
|
||||
t.Errorf("output does not contain tag %q\noutput: %s", tag, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.k3nny.fr/releaser/internal/commits"
|
||||
)
|
||||
|
||||
// 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).
|
||||
// Returns ("", false) when there are no releasable commits.
|
||||
func Next(major, minor, currentPatch int, types []commits.Type) (string, bool) {
|
||||
for _, t := range types {
|
||||
if t != commits.TypeNone {
|
||||
return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.k3nny.fr/releaser/internal/commits"
|
||||
)
|
||||
|
||||
func TestNext(t *testing.T) {
|
||||
cases := []struct {
|
||||
desc string
|
||||
major, minor int
|
||||
currentPatch int
|
||||
types []commits.Type
|
||||
want string
|
||||
wantOk bool
|
||||
}{
|
||||
{
|
||||
desc: "first release with feat",
|
||||
major: 1, minor: 2, currentPatch: -1,
|
||||
types: []commits.Type{commits.TypeFeat},
|
||||
want: "1.2.0",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
desc: "bump from patch 3",
|
||||
major: 1, minor: 2, currentPatch: 3,
|
||||
types: []commits.Type{commits.TypeFix},
|
||||
want: "1.2.4",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
desc: "breaking change still bumps patch on release branch",
|
||||
major: 2, minor: 0, currentPatch: 1,
|
||||
types: []commits.Type{commits.TypeBreaking},
|
||||
want: "2.0.2",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
desc: "only chore commits — nothing to release",
|
||||
major: 1, minor: 2, currentPatch: 5,
|
||||
types: []commits.Type{commits.TypeNone, commits.TypeNone},
|
||||
want: "",
|
||||
wantOk: false,
|
||||
},
|
||||
{
|
||||
desc: "no commits at all",
|
||||
major: 1, minor: 2, currentPatch: 0,
|
||||
types: nil,
|
||||
want: "",
|
||||
wantOk: false,
|
||||
},
|
||||
{
|
||||
desc: "one releasable commit among chores",
|
||||
major: 1, minor: 3, currentPatch: 7,
|
||||
types: []commits.Type{commits.TypeNone, commits.TypeFix, commits.TypeNone},
|
||||
want: "1.3.8",
|
||||
wantOk: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.desc, func(t *testing.T) {
|
||||
got, ok := Next(c.major, c.minor, c.currentPatch, c.types)
|
||||
if ok != c.wantOk {
|
||||
t.Errorf("ok=%v, want %v", ok, c.wantOk)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("got %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user