feat: initial stamp implementation
- stamp add: create .stamp/*.md changeset files (interactive + --no-interactive) - stamp status: show pending stamps and projected next versions (plain + --json) - stamp version: consume stamps, bump semver, update CHANGELOGs, commit - stamp publish: create git tags, GitHub/Gitea releases, upload artifacts - stamp comment: post/update idempotent PR comments via GitHub/Gitea API - Supports monorepos via stamp.toml [[projects]] blocks - Changeset files support YAML (---) and TOML (+++) frontmatter - Semver + pre-release support (alpha, beta, rc) - Examples and workflow documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
91
internal/changelog/changelog.go
Normal file
91
internal/changelog/changelog.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package changelog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const header = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n"
|
||||
|
||||
// Entry holds the data for one changelog entry.
|
||||
type Entry struct {
|
||||
Version string
|
||||
Date time.Time
|
||||
Description string
|
||||
}
|
||||
|
||||
// Append prepends a new version entry to the CHANGELOG.md at the given path.
|
||||
// If the file doesn't exist it will be created with a standard header.
|
||||
func Append(path string, entry Entry) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("creating dir for %s: %w", path, err)
|
||||
}
|
||||
|
||||
existing, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
|
||||
section := formatSection(entry)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if len(existing) == 0 {
|
||||
buf.WriteString(header)
|
||||
buf.WriteString(section)
|
||||
} else {
|
||||
content := string(existing)
|
||||
// Insert after the header block if present, otherwise prepend.
|
||||
insertIdx := findInsertPoint(content)
|
||||
buf.WriteString(content[:insertIdx])
|
||||
buf.WriteString(section)
|
||||
buf.WriteString(content[insertIdx:])
|
||||
}
|
||||
|
||||
return os.WriteFile(path, buf.Bytes(), 0644)
|
||||
}
|
||||
|
||||
func formatSection(e Entry) string {
|
||||
date := e.Date.Format("2006-01-02")
|
||||
var buf bytes.Buffer
|
||||
fmt.Fprintf(&buf, "## [%s] - %s\n\n", e.Version, date)
|
||||
if strings.TrimSpace(e.Description) != "" {
|
||||
buf.WriteString(strings.TrimSpace(e.Description))
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// findInsertPoint returns the index in content where new version sections should be inserted.
|
||||
// It skips over the header (title + unreleased section if any), placing new entries at the top
|
||||
// of the version list.
|
||||
func findInsertPoint(content string) int {
|
||||
lines := strings.Split(content, "\n")
|
||||
for i, line := range lines {
|
||||
// Find first ## [version] line (not ## [Unreleased])
|
||||
if strings.HasPrefix(line, "## [") && !strings.HasPrefix(line, "## [Unreleased]") {
|
||||
// Return position of this line
|
||||
return positionOfLine(content, i)
|
||||
}
|
||||
}
|
||||
// No existing version sections: append at end of file
|
||||
return len(content)
|
||||
}
|
||||
|
||||
func positionOfLine(content string, lineNum int) int {
|
||||
pos := 0
|
||||
for i, ch := range content {
|
||||
if lineNum == 0 {
|
||||
return i
|
||||
}
|
||||
if ch == '\n' {
|
||||
lineNum--
|
||||
}
|
||||
_ = pos
|
||||
}
|
||||
return len(content)
|
||||
}
|
||||
107
internal/changelog/changelog_test.go
Normal file
107
internal/changelog/changelog_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package changelog_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/thokra/stamp/internal/changelog"
|
||||
)
|
||||
|
||||
var testDate = time.Date(2026, 3, 8, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func TestAppend_NewFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CHANGELOG.md")
|
||||
|
||||
err := changelog.Append(path, changelog.Entry{
|
||||
Version: "1.0.0",
|
||||
Date: testDate,
|
||||
Description: "Initial release",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
content := string(data)
|
||||
|
||||
if !strings.Contains(content, "## [1.0.0] - 2026-03-08") {
|
||||
t.Errorf("expected version header, got:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "Initial release") {
|
||||
t.Errorf("expected description in changelog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppend_ExistingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CHANGELOG.md")
|
||||
|
||||
// Write first version.
|
||||
_ = changelog.Append(path, changelog.Entry{
|
||||
Version: "1.0.0",
|
||||
Date: testDate,
|
||||
Description: "First release",
|
||||
})
|
||||
|
||||
// Append second version.
|
||||
_ = changelog.Append(path, changelog.Entry{
|
||||
Version: "1.1.0",
|
||||
Date: testDate.Add(24 * time.Hour),
|
||||
Description: "Second release",
|
||||
})
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
content := string(data)
|
||||
|
||||
if !strings.Contains(content, "## [1.0.0]") {
|
||||
t.Error("expected 1.0.0 section")
|
||||
}
|
||||
if !strings.Contains(content, "## [1.1.0]") {
|
||||
t.Error("expected 1.1.0 section")
|
||||
}
|
||||
|
||||
// New version should appear before old.
|
||||
idx110 := strings.Index(content, "## [1.1.0]")
|
||||
idx100 := strings.Index(content, "## [1.0.0]")
|
||||
if idx110 > idx100 {
|
||||
t.Error("expected 1.1.0 to appear before 1.0.0 (newest first)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppend_EmptyDescription(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CHANGELOG.md")
|
||||
|
||||
err := changelog.Append(path, changelog.Entry{
|
||||
Version: "2.0.0",
|
||||
Date: testDate,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
if !strings.Contains(string(data), "## [2.0.0]") {
|
||||
t.Error("expected version header even with empty description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppend_CreatesDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sub", "dir", "CHANGELOG.md")
|
||||
|
||||
err := changelog.Append(path, changelog.Entry{
|
||||
Version: "1.0.0",
|
||||
Date: testDate,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("expected file to be created: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user