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:
Thomas
2026-03-08 20:56:23 +01:00
commit 77462f5e8a
25 changed files with 2518 additions and 0 deletions

96
internal/github/github.go Normal file
View File

@@ -0,0 +1,96 @@
package github
import (
"context"
"fmt"
"os"
"strings"
gh "github.com/google/go-github/v70/github"
)
// Client wraps the GitHub REST API.
type Client struct {
client *gh.Client
owner string
repo string
}
// NewClient creates a GitHub client from the GITHUB_TOKEN environment variable.
// The repoSlug must be in "owner/repo" format.
func NewClient(repoSlug string) (*Client, error) {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
return nil, fmt.Errorf("GITHUB_TOKEN environment variable is not set")
}
parts := strings.SplitN(repoSlug, "/", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid repo slug %q: expected owner/repo", repoSlug)
}
return &Client{
client: gh.NewClient(nil).WithAuthToken(token),
owner: parts[0],
repo: parts[1],
}, nil
}
// CreateRelease creates a GitHub release for the given tag.
func (c *Client) CreateRelease(ctx context.Context, tag, name, body string, draft, prerelease bool) (int64, error) {
rel, _, err := c.client.Repositories.CreateRelease(ctx, c.owner, c.repo, &gh.RepositoryRelease{
TagName: gh.Ptr(tag),
Name: gh.Ptr(name),
Body: gh.Ptr(body),
Draft: gh.Ptr(draft),
Prerelease: gh.Ptr(prerelease),
})
if err != nil {
return 0, fmt.Errorf("creating GitHub release %s: %w", tag, err)
}
return rel.GetID(), nil
}
// UploadAsset uploads a file to an existing release.
func (c *Client) UploadAsset(ctx context.Context, releaseID int64, filePath string) error {
f, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("opening %s: %w", filePath, err)
}
defer f.Close()
name := strings.Split(filePath, "/")
fileName := name[len(name)-1]
_, _, err = c.client.Repositories.UploadReleaseAsset(ctx, c.owner, c.repo, releaseID,
&gh.UploadOptions{Name: fileName}, f)
if err != nil {
return fmt.Errorf("uploading %s to release %d: %w", fileName, releaseID, err)
}
return nil
}
const commentMarker = "<!-- stamp-pr-comment -->"
// UpsertPRComment posts or updates a stamped PR comment (identified by a hidden marker).
func (c *Client) UpsertPRComment(ctx context.Context, prNumber int, body string) error {
markedBody := commentMarker + "\n" + body
// List existing comments to find ours.
comments, _, err := c.client.Issues.ListComments(ctx, c.owner, c.repo, prNumber, nil)
if err != nil {
return fmt.Errorf("listing PR comments: %w", err)
}
for _, comment := range comments {
if strings.Contains(comment.GetBody(), commentMarker) {
// Update existing comment.
_, _, err = c.client.Issues.EditComment(ctx, c.owner, c.repo, comment.GetID(),
&gh.IssueComment{Body: gh.Ptr(markedBody)})
return err
}
}
// Create new comment.
_, _, err = c.client.Issues.CreateComment(ctx, c.owner, c.repo, prNumber,
&gh.IssueComment{Body: gh.Ptr(markedBody)})
return err
}