refactor: rename releaseprep to vociferate
This commit is contained in:
267
internal/vociferate/vociferate.go
Normal file
267
internal/vociferate/vociferate.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package vociferate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultVersionFile = "internal/vociferate/version/version.go"
|
||||
defaultVersionExpr = `const String = "([^"]+)"`
|
||||
defaultChangelog = "changelog.md"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
VersionFile string
|
||||
VersionPattern string
|
||||
Changelog string
|
||||
}
|
||||
|
||||
type semver struct {
|
||||
major int
|
||||
minor int
|
||||
patch int
|
||||
}
|
||||
|
||||
type resolvedOptions struct {
|
||||
VersionFile string
|
||||
VersionExpr *regexp.Regexp
|
||||
Changelog string
|
||||
}
|
||||
|
||||
func Prepare(rootDir, version, releaseDate string, options Options) error {
|
||||
normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v")
|
||||
if normalizedVersion == "" {
|
||||
return fmt.Errorf("version must not be empty")
|
||||
}
|
||||
|
||||
releaseDate = strings.TrimSpace(releaseDate)
|
||||
if releaseDate == "" {
|
||||
return fmt.Errorf("release date must not be empty")
|
||||
}
|
||||
|
||||
resolved, err := resolveOptions(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := updateVersionFile(rootDir, normalizedVersion, resolved); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := updateChangelog(rootDir, normalizedVersion, releaseDate, resolved.Changelog); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func RecommendedTag(rootDir string, options Options) (string, error) {
|
||||
resolved, err := resolveOptions(options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
currentVersion, err := readCurrentVersion(rootDir, resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
unreleasedBody, err := readUnreleasedBody(rootDir, resolved.Changelog)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
parsed, err := parseSemver(currentVersion)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(unreleasedBody, "### Breaking"), sectionHasEntries(unreleasedBody, "Removed"):
|
||||
parsed.major++
|
||||
parsed.minor = 0
|
||||
parsed.patch = 0
|
||||
case sectionHasEntries(unreleasedBody, "Added"):
|
||||
parsed.minor++
|
||||
parsed.patch = 0
|
||||
default:
|
||||
parsed.patch++
|
||||
}
|
||||
|
||||
return fmt.Sprintf("v%d.%d.%d", parsed.major, parsed.minor, parsed.patch), nil
|
||||
}
|
||||
|
||||
func sectionHasEntries(unreleasedBody, sectionName string) bool {
|
||||
heading := "### " + sectionName
|
||||
sectionStart := strings.Index(unreleasedBody, heading)
|
||||
if sectionStart == -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
afterHeading := unreleasedBody[sectionStart+len(heading):]
|
||||
afterHeading = strings.TrimPrefix(afterHeading, "\r")
|
||||
afterHeading = strings.TrimPrefix(afterHeading, "\n")
|
||||
|
||||
nextHeading := strings.Index(afterHeading, "\n### ")
|
||||
sectionBody := afterHeading
|
||||
if nextHeading != -1 {
|
||||
sectionBody = afterHeading[:nextHeading]
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sectionBody) != ""
|
||||
}
|
||||
|
||||
func resolveOptions(options Options) (resolvedOptions, error) {
|
||||
versionFile := strings.TrimSpace(options.VersionFile)
|
||||
if versionFile == "" {
|
||||
versionFile = defaultVersionFile
|
||||
}
|
||||
|
||||
pattern := strings.TrimSpace(options.VersionPattern)
|
||||
if pattern == "" {
|
||||
pattern = defaultVersionExpr
|
||||
}
|
||||
versionExpr, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return resolvedOptions{}, fmt.Errorf("compile version pattern: %w", err)
|
||||
}
|
||||
if versionExpr.NumSubexp() != 1 {
|
||||
return resolvedOptions{}, fmt.Errorf("version pattern must contain exactly one capture group")
|
||||
}
|
||||
|
||||
changelog := strings.TrimSpace(options.Changelog)
|
||||
if changelog == "" {
|
||||
changelog = defaultChangelog
|
||||
}
|
||||
|
||||
return resolvedOptions{VersionFile: versionFile, VersionExpr: versionExpr, Changelog: changelog}, nil
|
||||
}
|
||||
|
||||
func updateVersionFile(rootDir, version string, options resolvedOptions) error {
|
||||
path := filepath.Join(rootDir, options.VersionFile)
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read version file: %w", err)
|
||||
}
|
||||
|
||||
match := options.VersionExpr.FindStringSubmatch(string(contents))
|
||||
if len(match) < 2 {
|
||||
return fmt.Errorf("version value not found in %s", path)
|
||||
}
|
||||
|
||||
replacement := strings.Replace(match[0], match[1], version, 1)
|
||||
updated := strings.Replace(string(contents), match[0], replacement, 1)
|
||||
if updated == string(contents) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||
return fmt.Errorf("write version file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateChangelog(rootDir, version, releaseDate, changelogPath string) error {
|
||||
unreleasedBody, text, afterHeader, nextSectionStart, path, err := readChangelogState(rootDir, changelogPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(unreleasedBody) == "" {
|
||||
return fmt.Errorf("unreleased section is empty")
|
||||
}
|
||||
|
||||
newSection := fmt.Sprintf("## [%s] - %s\n", version, releaseDate)
|
||||
newSection += "\n" + unreleasedBody
|
||||
if !strings.HasSuffix(newSection, "\n") {
|
||||
newSection += "\n"
|
||||
}
|
||||
|
||||
updated := text[:afterHeader] + "\n" + newSection + text[nextSectionStart:]
|
||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||
return fmt.Errorf("write changelog: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readCurrentVersion(rootDir string, options resolvedOptions) (string, error) {
|
||||
path := filepath.Join(rootDir, options.VersionFile)
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read version file: %w", err)
|
||||
}
|
||||
|
||||
match := options.VersionExpr.FindStringSubmatch(string(contents))
|
||||
if len(match) < 2 {
|
||||
return "", fmt.Errorf("version value not found in %s", path)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(match[1]), nil
|
||||
}
|
||||
|
||||
func readUnreleasedBody(rootDir, changelogPath string) (string, error) {
|
||||
unreleasedBody, _, _, _, _, err := readChangelogState(rootDir, changelogPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(unreleasedBody) == "" {
|
||||
return "", fmt.Errorf("unreleased section is empty")
|
||||
}
|
||||
|
||||
return unreleasedBody, nil
|
||||
}
|
||||
|
||||
func readChangelogState(rootDir, changelogPath string) (string, string, int, int, string, error) {
|
||||
path := filepath.Join(rootDir, changelogPath)
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", "", 0, 0, "", fmt.Errorf("read changelog: %w", err)
|
||||
}
|
||||
|
||||
text := string(contents)
|
||||
unreleasedHeader := "## [Unreleased]\n"
|
||||
start := strings.Index(text, unreleasedHeader)
|
||||
if start == -1 {
|
||||
return "", "", 0, 0, "", fmt.Errorf("unreleased section not found in changelog")
|
||||
}
|
||||
|
||||
afterHeader := start + len(unreleasedHeader)
|
||||
nextSectionRelative := strings.Index(text[afterHeader:], "\n## [")
|
||||
if nextSectionRelative == -1 {
|
||||
nextSectionRelative = len(text[afterHeader:])
|
||||
}
|
||||
nextSectionStart := afterHeader + nextSectionRelative
|
||||
unreleasedBody := strings.TrimLeft(text[afterHeader:nextSectionStart], "\n")
|
||||
|
||||
return unreleasedBody, text, afterHeader, nextSectionStart, path, nil
|
||||
}
|
||||
|
||||
func parseSemver(version string) (semver, error) {
|
||||
parts := strings.Split(strings.TrimSpace(version), ".")
|
||||
if len(parts) != 3 {
|
||||
return semver{}, fmt.Errorf("version %q is not semantic major.minor.patch", version)
|
||||
}
|
||||
|
||||
major, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return semver{}, fmt.Errorf("parse major version: %w", err)
|
||||
}
|
||||
minor, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return semver{}, fmt.Errorf("parse minor version: %w", err)
|
||||
}
|
||||
patch, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return semver{}, fmt.Errorf("parse patch version: %w", err)
|
||||
}
|
||||
|
||||
return semver{major: major, minor: minor, patch: patch}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user