1
0
Fork 0
mirror of https://github.com/Luzifer/git-changerelease.git synced 2024-12-20 19:11:17 +00:00
git-changerelease/main.go
Knut Ahlers 4f23c1ae0d
Fix: Remove whitespace from old log
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2023-11-11 15:29:38 +01:00

260 lines
7.1 KiB
Go

package main
import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"regexp"
"strings"
"text/template"
"time"
"github.com/mitchellh/go-homedir"
"github.com/sirupsen/logrus"
"github.com/Luzifer/rconfig/v2"
)
const (
fileModeChangelog = 0o644
fileModeConfig = 0o600
)
var (
cfg = struct {
ChangelogFile string `flag:"changelog" default:"History.md" description:"File to write the changelog to"`
ConfigFile string `flag:"config" default:"~/.git_changerelease.yaml" description:"Location of the configuration file"`
LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"`
MkConfig bool `flag:"create-config" default:"false" description:"Copy an example configuration file to the location of --config"`
NoEdit bool `flag:"no-edit" default:"false" description:"Do not open the $EDITOR to modify the changelog"`
PreRelease string `flag:"pre-release" default:"" description:"Pre-Release information to append to the version (i.e. 'beta' or 'alpha.1')"`
ReleaseMeta string `flag:"release-meta" default:"" description:"Release metadata to append to the version (i.e. 'exp.sha.5114f85' or '20130313144700')"`
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
}{}
config *configFile
version = "dev"
matchers = make(map[*regexp.Regexp]semVerBump)
errExitZero = errors.New("should exit zero now")
)
func initApp() (err error) {
rconfig.AutoEnv(true)
if err = rconfig.Parse(&cfg); err != nil {
return fmt.Errorf("parsing cli options: %w", err)
}
if cfg.VersionAndExit {
fmt.Printf("git-changerelease %s\n", version) //nolint:forbidigo // Fine in this case
return errExitZero
}
cfg.ConfigFile, err = homedir.Expand(cfg.ConfigFile)
if err != nil {
return fmt.Errorf("expanding file path: %w", err)
}
var l logrus.Level
if l, err = logrus.ParseLevel(cfg.LogLevel); err != nil {
return fmt.Errorf("parsing log-level: %w", err)
}
logrus.SetLevel(l)
if cfg.MkConfig {
if err = os.WriteFile(cfg.ConfigFile, mustAsset("assets/git_changerelease.yaml"), fileModeConfig); err != nil {
return fmt.Errorf("writing example config to %q: %w", cfg.ConfigFile, err)
}
logrus.Infof("wrote an example configuration to %q", cfg.ConfigFile)
return errExitZero
}
if !cfg.NoEdit && os.Getenv("EDITOR") == "" {
return errors.New("tried to open the changelog in the editor but there is no $EDITOR in your env")
}
if config, err = loadConfig(); err != nil {
return fmt.Errorf("loading config file: %w", err)
}
// Collect matchers
if err = loadMatcherRegex(config.MatchPatch, semVerBumpPatch); err != nil {
return fmt.Errorf("loading patch matcher: %w", err)
}
if err = loadMatcherRegex(config.MatchMajor, semVerBumpMajor); err != nil {
return fmt.Errorf("loading major matcher: %w", err)
}
if cfg.ChangelogFile, err = filenameInGitRoot(cfg.ChangelogFile); err != nil {
return fmt.Errorf("getting absolute path to changelog file: %w", err)
}
return nil
}
func loadMatcherRegex(matches []string, bump semVerBump) error {
for _, match := range matches {
r, err := regexp.Compile(match)
if err != nil {
return fmt.Errorf("parsing regex %q: %w", match, err)
}
matchers[r] = bump
}
return nil
}
func main() {
var err error
if err = initApp(); err != nil {
if errors.Is(err, errExitZero) {
os.Exit(0)
}
logrus.WithError(err).Fatal("initializing app")
}
// Get last tag
lastTag, err := gitSilent("describe", "--tags", "--abbrev=0", `--match=v[0-9]*\.[0-9]*\.[0-9]*`)
if err != nil {
lastTag = "0.0.0"
}
logs, err := fetchGitLogs(lastTag, err != nil)
if err != nil {
logrus.WithError(err).Fatal("fetching git logs")
}
if len(logs) == 0 {
logrus.Info("found no changes since last tag, stopping now.")
return
}
// Generate new version
newVersion, err := newVersionFromLogs(lastTag, logs)
if err != nil {
logrus.WithError(err).Fatal("bumping version")
}
// Render log
if newVersion, err = renderLogAndGetVersion(newVersion, logs); err != nil {
logrus.WithError(err).Fatal("writing changelog")
}
// Write the tag
if err = applyTag("v" + newVersion.String()); err != nil {
logrus.WithError(err).Fatal("applying tag")
}
}
func newVersionFromLogs(lastTag string, logs []commit) (*semVer, error) {
// Tetermine increase type
semVerBumpType, err := selectBumpType(logs)
if err != nil {
return nil, fmt.Errorf("determining bump type: %w", err)
}
// Generate new version
newVersion, err := parseSemVer(lastTag)
if err != nil {
return nil, fmt.Errorf("parsing previous version: %w", err)
}
newVersion.Bump(semVerBumpType)
if err = newVersion.SetPrerelease(cfg.PreRelease); err != nil {
return newVersion, fmt.Errorf("setting prerelease: %w", err)
}
if err = newVersion.SetMetadata(cfg.ReleaseMeta); err != nil {
return newVersion, fmt.Errorf("setting metadata: %w", err)
}
return newVersion, nil
}
func readChangelog() (string, error) {
if _, err := os.Stat(cfg.ChangelogFile); err != nil {
if errors.Is(err, fs.ErrNotExist) {
logrus.Warn("changelog file does not yet exist, creating one")
return "", nil
}
return "", fmt.Errorf("getting file stat: %w", err)
}
d, err := os.ReadFile(cfg.ChangelogFile)
if err != nil {
return "", fmt.Errorf("reading file: %w", err)
}
return string(d), nil
}
func renderLogAndGetVersion(newVersion *semVer, logs []commit) (*semVer, error) {
oldLog, err := readChangelog()
if err != nil {
return nil, fmt.Errorf("reading old changelog: %w", err)
}
c, err := renderTemplate("log_template", mustAsset("assets/log_template.md"), struct {
NextVersion *semVer
Now time.Time
LogLines []commit
OldLog string
}{
NextVersion: newVersion,
Now: time.Now(),
LogLines: logs,
OldLog: strings.TrimSpace(oldLog),
})
if err != nil {
return nil, fmt.Errorf("rendering log: %w", err)
}
// Strip whitespaces on start / end
c = bytes.TrimSpace(c)
if err = os.WriteFile(cfg.ChangelogFile, c, fileModeChangelog); err != nil {
return nil, fmt.Errorf("writing changelog: %w", err)
}
// Spawning editor
if !cfg.NoEdit {
editor := exec.Command(os.Getenv("EDITOR"), cfg.ChangelogFile) //#nosec:G204 // This is intended to use OS editor with configured changelog file
editor.Stdin = os.Stdin
editor.Stdout = os.Stdout
editor.Stderr = os.Stderr
if err = editor.Run(); err != nil {
return nil, fmt.Errorf("editor process caused error: %w", err)
}
}
// Read back version from changelog file
changelog := strings.Split(string(c), "\n")
if len(changelog) < 1 {
return nil, errors.New("changelog is empty, no way to read back the version")
}
newVersion, err = parseSemVer(strings.Split(changelog[0], " ")[1])
if err != nil {
return nil, fmt.Errorf("parsing new version from log: %w", err)
}
return newVersion, nil
}
func renderTemplate(name string, tplSrc []byte, values any) ([]byte, error) {
tpl, err := template.New(name).Parse(string(tplSrc))
if err != nil {
return nil, fmt.Errorf("parsing template: %w", err)
}
buf := new(bytes.Buffer)
if err := tpl.Execute(buf, values); err != nil {
return nil, fmt.Errorf("executing template: %w", err)
}
return buf.Bytes(), nil
}