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:
148
internal/config/config.go
Normal file
148
internal/config/config.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
const DefaultChangesetDir = ".stamp"
|
||||
const ConfigFileName = "stamp.toml"
|
||||
|
||||
// Config is the root configuration parsed from stamp.toml.
|
||||
type Config struct {
|
||||
Config GlobalConfig `toml:"config"`
|
||||
Projects []Project `toml:"projects"`
|
||||
}
|
||||
|
||||
// GlobalConfig holds repo-level settings.
|
||||
type GlobalConfig struct {
|
||||
BaseBranch string `toml:"base_branch,omitempty"`
|
||||
ChangesetDir string `toml:"changeset_dir,omitempty"`
|
||||
}
|
||||
|
||||
// Project represents a single project in the monorepo.
|
||||
type Project struct {
|
||||
Name string `toml:"name"`
|
||||
Path string `toml:"path"`
|
||||
Version string `toml:"version"`
|
||||
Changelog string `toml:"changelog,omitempty"`
|
||||
Publish PublishConfig `toml:"publish,omitempty"`
|
||||
}
|
||||
|
||||
// PublishConfig controls what happens during stamp publish.
|
||||
type PublishConfig struct {
|
||||
Tags *bool `toml:"tags"`
|
||||
Releases *bool `toml:"releases"`
|
||||
Artifacts []string `toml:"artifacts"`
|
||||
}
|
||||
|
||||
// ChangesetDir returns the configured changeset directory, defaulting to .stamp.
|
||||
func (c *Config) ChangesetDir() string {
|
||||
if c.Config.ChangesetDir != "" {
|
||||
return c.Config.ChangesetDir
|
||||
}
|
||||
return DefaultChangesetDir
|
||||
}
|
||||
|
||||
// BaseBranch returns the configured base branch, defaulting to "main".
|
||||
func (c *Config) BaseBranch() string {
|
||||
if c.Config.BaseBranch != "" {
|
||||
return c.Config.BaseBranch
|
||||
}
|
||||
return "main"
|
||||
}
|
||||
|
||||
// FindProject returns the project with the given name, or nil if not found.
|
||||
func (c *Config) FindProject(name string) *Project {
|
||||
for i := range c.Projects {
|
||||
if c.Projects[i].Name == name {
|
||||
return &c.Projects[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProjectNames returns all project names.
|
||||
func (c *Config) ProjectNames() []string {
|
||||
names := make([]string, len(c.Projects))
|
||||
for i, p := range c.Projects {
|
||||
names[i] = p.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ChangelogPath returns the path to CHANGELOG.md for a project, relative to repoRoot.
|
||||
func (p *Project) ChangelogPath(repoRoot string) string {
|
||||
changelog := p.Changelog
|
||||
if changelog == "" {
|
||||
changelog = "CHANGELOG.md"
|
||||
}
|
||||
return filepath.Join(repoRoot, p.Path, changelog)
|
||||
}
|
||||
|
||||
// PublishTags returns true unless explicitly disabled.
|
||||
func (p *PublishConfig) PublishTags() bool {
|
||||
return p.Tags == nil || *p.Tags
|
||||
}
|
||||
|
||||
// PublishReleases returns true unless explicitly disabled.
|
||||
func (p *PublishConfig) PublishReleases() bool {
|
||||
return p.Releases == nil || *p.Releases
|
||||
}
|
||||
|
||||
// Load reads and validates stamp.toml from the given directory.
|
||||
func Load(dir string) (*Config, error) {
|
||||
path := filepath.Join(dir, ConfigFileName)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := toml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", path, err)
|
||||
}
|
||||
|
||||
if err := validate(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid %s: %w", path, err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Save writes the config back to stamp.toml in the given directory.
|
||||
func Save(dir string, cfg *Config) error {
|
||||
path := filepath.Join(dir, ConfigFileName)
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
enc := toml.NewEncoder(f)
|
||||
return enc.Encode(cfg)
|
||||
}
|
||||
|
||||
func validate(cfg *Config) error {
|
||||
if len(cfg.Projects) == 0 {
|
||||
return fmt.Errorf("at least one [[projects]] entry is required")
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for i, p := range cfg.Projects {
|
||||
if p.Name == "" {
|
||||
return fmt.Errorf("projects[%d]: name is required", i)
|
||||
}
|
||||
if seen[p.Name] {
|
||||
return fmt.Errorf("projects[%d]: duplicate project name %q", i, p.Name)
|
||||
}
|
||||
seen[p.Name] = true
|
||||
if p.Version == "" {
|
||||
return fmt.Errorf("project %q: version is required", p.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
132
internal/config/config_test.go
Normal file
132
internal/config/config_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/thokra/stamp/internal/config"
|
||||
)
|
||||
|
||||
const validTOML = `
|
||||
[config]
|
||||
base_branch = "main"
|
||||
changeset_dir = ".stamp"
|
||||
|
||||
[[projects]]
|
||||
name = "my-app"
|
||||
path = "apps/my-app"
|
||||
version = "1.2.3"
|
||||
|
||||
[[projects]]
|
||||
name = "my-lib"
|
||||
path = "libs/my-lib"
|
||||
version = "0.1.0"
|
||||
`
|
||||
|
||||
func TestLoad_Valid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte(validTOML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.Projects) != 2 {
|
||||
t.Fatalf("expected 2 projects, got %d", len(cfg.Projects))
|
||||
}
|
||||
if cfg.BaseBranch() != "main" {
|
||||
t.Errorf("expected base_branch=main, got %s", cfg.BaseBranch())
|
||||
}
|
||||
if cfg.ChangesetDir() != ".stamp" {
|
||||
t.Errorf("expected changeset_dir=.stamp, got %s", cfg.ChangesetDir())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Defaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
minimal := "[[projects]]\nname = \"app\"\npath = \".\"\nversion = \"1.0.0\"\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte(minimal), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.BaseBranch() != "main" {
|
||||
t.Errorf("default base_branch should be main, got %s", cfg.BaseBranch())
|
||||
}
|
||||
if cfg.ChangesetDir() != ".stamp" {
|
||||
t.Errorf("default changeset_dir should be .stamp, got %s", cfg.ChangesetDir())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingFile(t *testing.T) {
|
||||
_, err := config.Load(t.TempDir())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing stamp.toml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_NoProjects(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte("[config]\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := config.Load(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for config with no projects")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_DuplicateProjectName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dup := `
|
||||
[[projects]]
|
||||
name = "app"
|
||||
path = "."
|
||||
version = "1.0.0"
|
||||
|
||||
[[projects]]
|
||||
name = "app"
|
||||
path = "other"
|
||||
version = "2.0.0"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte(dup), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := config.Load(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate project name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindProject(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte(validTOML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, _ := config.Load(dir)
|
||||
|
||||
if p := cfg.FindProject("my-app"); p == nil {
|
||||
t.Error("expected to find my-app")
|
||||
}
|
||||
if p := cfg.FindProject("nonexistent"); p != nil {
|
||||
t.Error("expected nil for nonexistent project")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishDefaults(t *testing.T) {
|
||||
var pc config.PublishConfig
|
||||
if !pc.PublishTags() {
|
||||
t.Error("tags should default to true")
|
||||
}
|
||||
if !pc.PublishReleases() {
|
||||
t.Error("releases should default to true")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user