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

@@ -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") != ""
}