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