This commit is contained in:
Thomas
2026-03-12 23:41:12 +01:00
parent 8049c505a0
commit 7c1a20465a
10 changed files with 1057 additions and 120 deletions

View File

@@ -82,3 +82,36 @@ func TagExists(dir, tag string) (bool, error) {
}
return strings.TrimSpace(out) == tag, nil
}
// BranchExists returns true if the given local branch exists.
func BranchExists(dir, branch string) (bool, error) {
out, err := Run(dir, "branch", "--list", branch)
if err != nil {
return false, err
}
return strings.TrimSpace(out) != "", nil
}
// CheckoutBranch checks out an existing branch.
func CheckoutBranch(dir, branch string) error {
_, err := Run(dir, "checkout", branch)
return err
}
// CheckoutNewBranch creates and checks out a new branch from the given base.
func CheckoutNewBranch(dir, branch, base string) error {
_, err := Run(dir, "checkout", "-B", branch, base)
return err
}
// PushBranch force-pushes a branch to origin.
func PushBranch(dir, branch string) error {
_, err := Run(dir, "push", "--force-with-lease", "origin", branch)
return err
}
// FetchOrigin fetches from origin.
func FetchOrigin(dir string) error {
_, err := Run(dir, "fetch", "origin")
return err
}

View File

@@ -8,6 +8,8 @@ import (
giteaSDK "code.gitea.io/sdk/gitea"
)
const prMarker = "<!-- stamp-release-pr -->"
// Client wraps the Gitea API.
type Client struct {
client *giteaSDK.Client
@@ -74,6 +76,51 @@ func (c *Client) UploadAsset(releaseID int64, filePath string) error {
return nil
}
// UpsertPR creates or updates a stamp release PR on the given base branch.
// It identifies the existing PR by scanning open PRs for the hidden marker in the body.
func (c *Client) UpsertPR(head, base, title, body string) (int64, string, error) {
markedBody := prMarker + "\n" + body
// Search open PRs for an existing stamp release PR (Gitea SDK does not support
// filtering by head branch, so we filter client-side).
prs, _, err := c.client.ListRepoPullRequests(c.owner, c.repo, giteaSDK.ListPullRequestsOptions{
State: giteaSDK.StateOpen,
})
if err != nil {
return 0, "", fmt.Errorf("listing pull requests: %w", err)
}
for _, pr := range prs {
if pr.Head != nil && pr.Head.Name != head {
continue
}
if strings.Contains(pr.Body, prMarker) {
// Update existing PR.
updated, _, err := c.client.EditPullRequest(c.owner, c.repo, pr.Index,
giteaSDK.EditPullRequestOption{
Title: title,
Body: &markedBody,
})
if err != nil {
return 0, "", fmt.Errorf("updating pull request #%d: %w", pr.Index, err)
}
return updated.Index, updated.HTMLURL, nil
}
}
// Create a new PR.
pr, _, err := c.client.CreatePullRequest(c.owner, c.repo, giteaSDK.CreatePullRequestOption{
Head: head,
Base: base,
Title: title,
Body: markedBody,
})
if err != nil {
return 0, "", fmt.Errorf("creating pull request: %w", err)
}
return pr.Index, pr.HTMLURL, nil
}
// UpsertPRComment posts or updates a stamped PR comment.
func (c *Client) UpsertPRComment(prNumber int, body string) error {
markedBody := commentMarker + "\n" + body

View File

@@ -94,3 +94,49 @@ func (c *Client) UpsertPRComment(ctx context.Context, prNumber int, body string)
&gh.IssueComment{Body: gh.Ptr(markedBody)})
return err
}
const prMarker = "<!-- stamp-release-pr -->"
// UpsertPR creates or updates a release PR from head into base.
// It identifies a previously-created PR by the hidden marker in the body.
// Returns the PR number and the URL.
func (c *Client) UpsertPR(ctx context.Context, head, base, title, body string) (int, string, error) {
markedBody := prMarker + "\n" + body
// Search open PRs from this head branch.
prs, _, err := c.client.PullRequests.List(ctx, c.owner, c.repo, &gh.PullRequestListOptions{
State: "open",
Head: c.owner + ":" + head,
Base: base,
})
if err != nil {
return 0, "", fmt.Errorf("listing pull requests: %w", err)
}
for _, pr := range prs {
if strings.Contains(pr.GetBody(), prMarker) {
// Update existing PR.
updated, _, err := c.client.PullRequests.Edit(ctx, c.owner, c.repo, pr.GetNumber(),
&gh.PullRequest{
Title: gh.Ptr(title),
Body: gh.Ptr(markedBody),
})
if err != nil {
return 0, "", fmt.Errorf("updating pull request: %w", err)
}
return updated.GetNumber(), updated.GetHTMLURL(), nil
}
}
// Create new PR.
pr, _, err := c.client.PullRequests.Create(ctx, c.owner, c.repo, &gh.NewPullRequest{
Title: gh.Ptr(title),
Head: gh.Ptr(head),
Base: gh.Ptr(base),
Body: gh.Ptr(markedBody),
})
if err != nil {
return 0, "", fmt.Errorf("creating pull request: %w", err)
}
return pr.GetNumber(), pr.GetHTMLURL(), nil
}

View File

@@ -0,0 +1,278 @@
package releasepr
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"git.thokra.dev/thokra/stamp/internal/changelog"
"git.thokra.dev/thokra/stamp/internal/changeset"
"git.thokra.dev/thokra/stamp/internal/config"
"git.thokra.dev/thokra/stamp/internal/git"
giteaClient "git.thokra.dev/thokra/stamp/internal/gitea"
ghClient "git.thokra.dev/thokra/stamp/internal/github"
"git.thokra.dev/thokra/stamp/internal/semver"
)
const DefaultReleaseBranch = "stamp/release"
// Options controls release-pr behaviour.
type Options struct {
// RepoRoot is the absolute path to the repository root.
RepoRoot string
// RepoSlug is "owner/repo", required for API calls.
RepoSlug string
// BaseBranch is the branch the PR targets (e.g. "main").
BaseBranch string
// ReleaseBranch is the branch stamp commits to (default: "stamp/release").
ReleaseBranch string
// SnapshotID is an optional pre-release identifier forwarded to stamp version logic.
SnapshotID string
// DryRun prints what would happen without pushing or opening a PR.
DryRun bool
}
// BumpSummary holds the computed version change for one project.
type BumpSummary struct {
Name string
Current string
Next string
Bump string
}
// Run is the main entry point for `stamp release-pr`.
// It versions all pending stamps, commits to the release branch, pushes, and
// opens or updates a pull request against the base branch.
func Run(ctx context.Context, opts Options) error {
cfg, err := config.Load(opts.RepoRoot)
if err != nil {
return err
}
stampDir := filepath.Join(opts.RepoRoot, cfg.ChangesetDir())
changesets, err := changeset.ReadAll(stampDir)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("reading stamp files: %w", err)
}
if len(changesets) == 0 {
fmt.Println("No pending stamps found — nothing to do.")
return nil
}
// Compute version bumps for the PR body before mutating anything.
summaries, err := computeSummaries(cfg, changesets, opts.SnapshotID)
if err != nil {
return err
}
if opts.DryRun {
fmt.Println("[dry-run] Would create/update release PR with:")
for _, s := range summaries {
fmt.Printf(" %s: %s → %s (%s)\n", s.Name, s.Current, s.Next, s.Bump)
}
return nil
}
// Fetch so we have an up-to-date origin/<base>.
fmt.Println("Fetching from origin...")
if err := git.FetchOrigin(opts.RepoRoot); err != nil {
return fmt.Errorf("fetching origin: %w", err)
}
// Create or reset the release branch from origin/<base> so it is always a
// clean, fast-forwardable branch on top of the latest base.
base := "origin/" + opts.BaseBranch
fmt.Printf("Resetting %s from %s...\n", opts.ReleaseBranch, base)
if err := git.CheckoutNewBranch(opts.RepoRoot, opts.ReleaseBranch, base); err != nil {
return fmt.Errorf("checking out release branch: %w", err)
}
// Apply version changes (same logic as `stamp version --no-commit`).
changedFiles, err := applyVersions(opts.RepoRoot, cfg, changesets, opts.SnapshotID)
if err != nil {
return err
}
// Stage and commit.
if err := git.Add(opts.RepoRoot, changedFiles...); err != nil {
return fmt.Errorf("staging files: %w", err)
}
commitMsg := buildCommitMessage(summaries)
if err := git.Commit(opts.RepoRoot, commitMsg); err != nil {
return fmt.Errorf("committing version bump: %w", err)
}
// Push the release branch.
fmt.Printf("Pushing %s...\n", opts.ReleaseBranch)
if err := git.PushBranch(opts.RepoRoot, opts.ReleaseBranch); err != nil {
return fmt.Errorf("pushing release branch: %w", err)
}
// Open or update the PR.
title := buildPRTitle(summaries)
body := buildPRBody(summaries)
prNumber, prURL, err := upsertPR(ctx, opts, title, body)
if err != nil {
return err
}
fmt.Printf("✓ Release PR #%d ready: %s\n", prNumber, prURL)
return nil
}
// computeSummaries calculates the next version for every project that has pending stamps.
func computeSummaries(cfg *config.Config, changesets []*changeset.Changeset, snapshotID string) ([]BumpSummary, error) {
projectBumps := semver.ProjectBumps(changesets)
var summaries []BumpSummary
for _, project := range cfg.Projects {
bumps := projectBumps[project.Name]
if len(bumps) == 0 {
continue
}
highest := semver.HighestBump(bumps)
preID := snapshotID
if preID == "" {
preID = project.PreTag
}
if preID != "" {
highest = semver.PreReleaseBump(project.Version, highest)
}
next, err := semver.Bump(project.Version, highest, preID)
if err != nil {
return nil, fmt.Errorf("computing next version for %s: %w", project.Name, err)
}
summaries = append(summaries, BumpSummary{
Name: project.Name,
Current: project.Version,
Next: next,
Bump: string(highest),
})
}
return summaries, nil
}
// applyVersions runs the version-bump logic and returns the list of files that were changed.
// It mutates cfg.Projects in place (same as cmd_version.go).
func applyVersions(repoRoot string, cfg *config.Config, changesets []*changeset.Changeset, snapshotID string) ([]string, error) {
projectBumps := semver.ProjectBumps(changesets)
now := time.Now()
var changedFiles []string
for i := range cfg.Projects {
project := &cfg.Projects[i]
bumps := projectBumps[project.Name]
if len(bumps) == 0 {
continue
}
highest := semver.HighestBump(bumps)
preID := snapshotID
if preID == "" {
preID = project.PreTag
}
if preID != "" {
highest = semver.PreReleaseBump(project.Version, highest)
}
nextVer, err := semver.Bump(project.Version, highest, preID)
if err != nil {
return nil, fmt.Errorf("bumping %s: %w", project.Name, err)
}
var body string
for _, cs := range changesets {
if _, ok := cs.Bumps[project.Name]; ok {
if cs.Description != "" {
body += "- " + cs.Description + "\n"
}
}
}
clPath := project.ChangelogPath(repoRoot)
if err := changelog.Append(clPath, changelog.Entry{
Version: nextVer,
Date: now,
Description: body,
}); err != nil {
return nil, fmt.Errorf("updating changelog for %s: %w", project.Name, err)
}
changedFiles = append(changedFiles, clPath)
fmt.Printf(" %s: %s → %s\n", project.Name, project.Version, nextVer)
project.Version = nextVer
}
// Persist updated versions to stamp.toml.
if err := config.Save(repoRoot, cfg); err != nil {
return nil, fmt.Errorf("saving stamp.toml: %w", err)
}
changedFiles = append(changedFiles, config.ConfigPath(repoRoot))
// Delete consumed stamp files.
stampDir := filepath.Join(repoRoot, cfg.ChangesetDir())
if err := changeset.DeleteAll(stampDir); err != nil {
return nil, fmt.Errorf("deleting stamp files: %w", err)
}
changedFiles = append(changedFiles, stampDir)
return changedFiles, nil
}
func buildCommitMessage(summaries []BumpSummary) string {
msg := "chore: version packages\n\n"
for _, s := range summaries {
msg += fmt.Sprintf("- %s: %s → %s\n", s.Name, s.Current, s.Next)
}
return msg
}
func buildPRTitle(summaries []BumpSummary) string {
if len(summaries) == 1 {
s := summaries[0]
return fmt.Sprintf("chore: release %s v%s", s.Name, s.Next)
}
return fmt.Sprintf("chore: release %d packages", len(summaries))
}
func buildPRBody(summaries []BumpSummary) string {
body := "## 📦 Pending releases\n\n"
body += "This PR was created by `stamp release-pr`. Merge it to apply the version bumps and trigger publishing.\n\n"
body += "| Project | Current | Next | Bump |\n"
body += "|---------|---------|------|------|\n"
for _, s := range summaries {
body += fmt.Sprintf("| %s | %s | %s | `%s` |\n", s.Name, s.Current, s.Next, s.Bump)
}
return body
}
func upsertPR(ctx context.Context, opts Options, title, body string) (int, string, error) {
if isGitea() {
client, err := giteaClient.NewClient(opts.RepoSlug)
if err != nil {
return 0, "", err
}
num, url, err := client.UpsertPR(opts.ReleaseBranch, opts.BaseBranch, title, body)
if err != nil {
return 0, "", err
}
return int(num), url, nil
}
client, err := ghClient.NewClient(opts.RepoSlug)
if err != nil {
return 0, "", err
}
return client.UpsertPR(ctx, opts.ReleaseBranch, opts.BaseBranch, title, body)
}
func isGitea() bool {
return os.Getenv("GITEA_BASE_URL") != ""
}

View File

@@ -9,6 +9,25 @@ import (
"git.thokra.dev/thokra/stamp/internal/changeset"
)
// PreReleaseBump converts a regular bump type to its pre-release equivalent,
// taking into account whether the current version is already a pre-release.
// If it is, the bump is always BumpPreRelease (just increment the pre number).
// Otherwise the highest regular bump is promoted to its pre-release variant.
func PreReleaseBump(currentVersion string, highest changeset.BumpType) changeset.BumpType {
v, err := goSemver.NewVersion(currentVersion)
if err == nil && v.Prerelease() != "" {
return changeset.BumpPreRelease
}
switch highest {
case changeset.BumpMajor:
return changeset.BumpPreMajor
case changeset.BumpMinor:
return changeset.BumpPreMinor
default:
return changeset.BumpPrePatch
}
}
// CurrentVersion parses a version string and returns the semver object.
// It is exported so callers can inspect pre-release state without re-importing the semver library.
func CurrentVersion(version string) (*goSemver.Version, error) {