github.com/windmeup/goreleaser@v1.21.95/internal/pipe/changelog/changelog.go (about)

     1  // Package changelog provides the release changelog to goreleaser.
     2  package changelog
     3  
     4  import (
     5  	"errors"
     6  	"fmt"
     7  	"os"
     8  	"path/filepath"
     9  	"regexp"
    10  	"sort"
    11  	"strings"
    12  
    13  	"github.com/caarlos0/log"
    14  	"github.com/windmeup/goreleaser/internal/client"
    15  	"github.com/windmeup/goreleaser/internal/git"
    16  	"github.com/windmeup/goreleaser/internal/tmpl"
    17  	"github.com/windmeup/goreleaser/pkg/context"
    18  )
    19  
    20  // ErrInvalidSortDirection happens when the sort order is invalid.
    21  var ErrInvalidSortDirection = errors.New("invalid sort direction")
    22  
    23  const li = "* "
    24  
    25  type useChangelog string
    26  
    27  func (u useChangelog) formatable() bool {
    28  	return u != "github-native"
    29  }
    30  
    31  const (
    32  	useGit          = "git"
    33  	useGitHub       = "github"
    34  	useGitLab       = "gitlab"
    35  	useGitHubNative = "github-native"
    36  )
    37  
    38  // Pipe for checksums.
    39  type Pipe struct{}
    40  
    41  func (Pipe) String() string { return "generating changelog" }
    42  func (Pipe) Skip(ctx *context.Context) (bool, error) {
    43  	if ctx.Snapshot {
    44  		return true, nil
    45  	}
    46  	return tmpl.New(ctx).Bool(ctx.Config.Changelog.Skip)
    47  }
    48  
    49  // Run the pipe.
    50  func (Pipe) Run(ctx *context.Context) error {
    51  	notes, err := loadContent(ctx, ctx.ReleaseNotesFile, ctx.ReleaseNotesTmpl)
    52  	if err != nil {
    53  		return err
    54  	}
    55  	ctx.ReleaseNotes = notes
    56  
    57  	if ctx.ReleaseNotesFile != "" || ctx.ReleaseNotesTmpl != "" {
    58  		return nil
    59  	}
    60  
    61  	footer, err := loadContent(ctx, ctx.ReleaseFooterFile, ctx.ReleaseFooterTmpl)
    62  	if err != nil {
    63  		return err
    64  	}
    65  
    66  	header, err := loadContent(ctx, ctx.ReleaseHeaderFile, ctx.ReleaseHeaderTmpl)
    67  	if err != nil {
    68  		return err
    69  	}
    70  
    71  	if err := checkSortDirection(ctx.Config.Changelog.Sort); err != nil {
    72  		return err
    73  	}
    74  
    75  	entries, err := buildChangelog(ctx)
    76  	if err != nil {
    77  		return err
    78  	}
    79  
    80  	changes, err := formatChangelog(ctx, entries)
    81  	if err != nil {
    82  		return err
    83  	}
    84  	changelogElements := []string{changes}
    85  
    86  	if header != "" {
    87  		changelogElements = append([]string{header}, changelogElements...)
    88  	}
    89  	if footer != "" {
    90  		changelogElements = append(changelogElements, footer)
    91  	}
    92  
    93  	ctx.ReleaseNotes = strings.Join(changelogElements, "\n\n")
    94  	if !strings.HasSuffix(ctx.ReleaseNotes, "\n") {
    95  		ctx.ReleaseNotes += "\n"
    96  	}
    97  
    98  	path := filepath.Join(ctx.Config.Dist, "CHANGELOG.md")
    99  	log.WithField("changelog", path).Info("writing")
   100  	return os.WriteFile(path, []byte(ctx.ReleaseNotes), 0o644) // nolint: gosec
   101  }
   102  
   103  type changelogGroup struct {
   104  	title   string
   105  	entries []string
   106  	order   int
   107  }
   108  
   109  func title(s string, level int) string {
   110  	if s == "" {
   111  		return ""
   112  	}
   113  	return fmt.Sprintf("%s %s", strings.Repeat("#", level), s)
   114  }
   115  
   116  func newLineFor(ctx *context.Context) string {
   117  	if ctx.TokenType == context.TokenTypeGitLab || ctx.TokenType == context.TokenTypeGitea {
   118  		// We need two or more whitespace to let markdown interpret
   119  		// it as newline. See https://docs.gitlab.com/ee/user/markdown.html#newlines for details
   120  		log.Debug("is gitlab or gitea changelog")
   121  		return "   \n"
   122  	}
   123  
   124  	return "\n"
   125  }
   126  
   127  func abbrevEntry(s string, abbr int) string {
   128  	switch abbr {
   129  	case 0:
   130  		return s
   131  	case -1:
   132  		_, rest, _ := strings.Cut(s, " ")
   133  		return rest
   134  	default:
   135  		commit, rest, _ := strings.Cut(s, " ")
   136  		if abbr > len(commit) {
   137  			return s
   138  		}
   139  		return fmt.Sprintf("%s %s", commit[:abbr], rest)
   140  	}
   141  }
   142  
   143  func abbrev(entries []string, abbr int) []string {
   144  	result := make([]string, 0, len(entries))
   145  	for _, entry := range entries {
   146  		result = append(result, abbrevEntry(entry, abbr))
   147  	}
   148  	return result
   149  }
   150  
   151  func formatChangelog(ctx *context.Context, entries []string) (string, error) {
   152  	if !useChangelog(ctx.Config.Changelog.Use).formatable() {
   153  		return strings.Join(entries, newLineFor(ctx)), nil
   154  	}
   155  
   156  	entries = abbrev(entries, ctx.Config.Changelog.Abbrev)
   157  
   158  	result := []string{title("Changelog", 2)}
   159  	if len(ctx.Config.Changelog.Groups) == 0 {
   160  		log.Debug("not grouping entries")
   161  		return strings.Join(append(result, filterAndPrefixItems(entries)...), newLineFor(ctx)), nil
   162  	}
   163  
   164  	log.Debug("grouping entries")
   165  	var groups []changelogGroup
   166  	for _, group := range ctx.Config.Changelog.Groups {
   167  		item := changelogGroup{
   168  			title: title(group.Title, 3),
   169  			order: group.Order,
   170  		}
   171  		if group.Regexp == "" {
   172  			// If no regexp is provided, we purge all strikethrough entries and add remaining entries to the list
   173  			item.entries = filterAndPrefixItems(entries)
   174  			// clear array
   175  			entries = nil
   176  		} else {
   177  			re, err := regexp.Compile(group.Regexp)
   178  			if err != nil {
   179  				return "", fmt.Errorf("failed to group into %q: %w", group.Title, err)
   180  			}
   181  
   182  			log.Debugf("group: %#v", group)
   183  			i := 0
   184  			for _, entry := range entries {
   185  				match := re.MatchString(entry)
   186  				log.Debugf("entry: %s match: %b\n", entry, match)
   187  				if match {
   188  					item.entries = append(item.entries, li+entry)
   189  				} else {
   190  					// Keep unmatched entry.
   191  					entries[i] = entry
   192  					i++
   193  				}
   194  			}
   195  			entries = entries[:i]
   196  		}
   197  		groups = append(groups, item)
   198  
   199  		if len(entries) == 0 {
   200  			break // No more entries to process.
   201  		}
   202  	}
   203  
   204  	sort.Slice(groups, groupSort(groups))
   205  	for _, group := range groups {
   206  		if len(group.entries) > 0 {
   207  			result = append(result, group.title)
   208  			result = append(result, group.entries...)
   209  		}
   210  	}
   211  	return strings.Join(result, newLineFor(ctx)), nil
   212  }
   213  
   214  func groupSort(groups []changelogGroup) func(i, j int) bool {
   215  	return func(i, j int) bool {
   216  		return groups[i].order < groups[j].order
   217  	}
   218  }
   219  
   220  func filterAndPrefixItems(ss []string) []string {
   221  	var r []string
   222  	for _, s := range ss {
   223  		if s != "" {
   224  			r = append(r, li+s)
   225  		}
   226  	}
   227  	return r
   228  }
   229  
   230  func loadFromFile(file string) (string, error) {
   231  	bts, err := os.ReadFile(file)
   232  	if err != nil {
   233  		return "", err
   234  	}
   235  	log.WithField("file", file).Debugf("read %d bytes", len(bts))
   236  	return string(bts), nil
   237  }
   238  
   239  func checkSortDirection(mode string) error {
   240  	switch mode {
   241  	case "", "asc", "desc":
   242  		return nil
   243  	default:
   244  		return ErrInvalidSortDirection
   245  	}
   246  }
   247  
   248  func buildChangelog(ctx *context.Context) ([]string, error) {
   249  	l, err := getChangeloger(ctx)
   250  	if err != nil {
   251  		return nil, err
   252  	}
   253  	log, err := l.Log(ctx)
   254  	if err != nil {
   255  		return nil, err
   256  	}
   257  	entries := strings.Split(log, "\n")
   258  	if lastLine := entries[len(entries)-1]; strings.TrimSpace(lastLine) == "" {
   259  		entries = entries[0 : len(entries)-1]
   260  	}
   261  	if !useChangelog(ctx.Config.Changelog.Use).formatable() {
   262  		return entries, nil
   263  	}
   264  	entries, err = filterEntries(ctx, entries)
   265  	if err != nil {
   266  		return entries, err
   267  	}
   268  	return sortEntries(ctx, entries), nil
   269  }
   270  
   271  func filterEntries(ctx *context.Context, entries []string) ([]string, error) {
   272  	filters := ctx.Config.Changelog.Filters
   273  	if len(filters.Include) > 0 {
   274  		var newEntries []string
   275  		for _, filter := range filters.Include {
   276  			r, err := regexp.Compile(filter)
   277  			if err != nil {
   278  				return entries, err
   279  			}
   280  			newEntries = append(newEntries, keep(r, entries)...)
   281  		}
   282  		return newEntries, nil
   283  	}
   284  	for _, filter := range filters.Exclude {
   285  		r, err := regexp.Compile(filter)
   286  		if err != nil {
   287  			return entries, err
   288  		}
   289  		entries = remove(r, entries)
   290  	}
   291  	return entries, nil
   292  }
   293  
   294  func sortEntries(ctx *context.Context, entries []string) []string {
   295  	direction := ctx.Config.Changelog.Sort
   296  	if direction == "" {
   297  		return entries
   298  	}
   299  	result := make([]string, len(entries))
   300  	copy(result, entries)
   301  	sort.Slice(result, func(i, j int) bool {
   302  		imsg := extractCommitInfo(result[i])
   303  		jmsg := extractCommitInfo(result[j])
   304  		if direction == "asc" {
   305  			return strings.Compare(imsg, jmsg) < 0
   306  		}
   307  		return strings.Compare(imsg, jmsg) > 0
   308  	})
   309  	return result
   310  }
   311  
   312  func keep(filter *regexp.Regexp, entries []string) (result []string) {
   313  	for _, entry := range entries {
   314  		if filter.MatchString(extractCommitInfo(entry)) {
   315  			result = append(result, entry)
   316  		}
   317  	}
   318  	return result
   319  }
   320  
   321  func remove(filter *regexp.Regexp, entries []string) (result []string) {
   322  	for _, entry := range entries {
   323  		if !filter.MatchString(extractCommitInfo(entry)) {
   324  			result = append(result, entry)
   325  		}
   326  	}
   327  	return result
   328  }
   329  
   330  func extractCommitInfo(line string) string {
   331  	return strings.Join(strings.Split(line, " ")[1:], " ")
   332  }
   333  
   334  func getChangeloger(ctx *context.Context) (changeloger, error) {
   335  	switch ctx.Config.Changelog.Use {
   336  	case useGit:
   337  		fallthrough
   338  	case "":
   339  		return gitChangeloger{}, nil
   340  	case useGitHub:
   341  		fallthrough
   342  	case useGitLab:
   343  		return newSCMChangeloger(ctx)
   344  	case useGitHubNative:
   345  		return newGithubChangeloger(ctx)
   346  	default:
   347  		return nil, fmt.Errorf("invalid changelog.use: %q", ctx.Config.Changelog.Use)
   348  	}
   349  }
   350  
   351  func newGithubChangeloger(ctx *context.Context) (changeloger, error) {
   352  	cli, err := client.NewGitHubReleaseNotesGenerator(ctx, ctx.Token)
   353  	if err != nil {
   354  		return nil, err
   355  	}
   356  	repo, err := git.ExtractRepoFromConfig(ctx)
   357  	if err != nil {
   358  		return nil, err
   359  	}
   360  	if err := repo.CheckSCM(); err != nil {
   361  		return nil, err
   362  	}
   363  	return &githubNativeChangeloger{
   364  		client: cli,
   365  		repo: client.Repo{
   366  			Owner: repo.Owner,
   367  			Name:  repo.Name,
   368  		},
   369  	}, nil
   370  }
   371  
   372  func newSCMChangeloger(ctx *context.Context) (changeloger, error) {
   373  	cli, err := client.New(ctx)
   374  	if err != nil {
   375  		return nil, err
   376  	}
   377  	repo, err := git.ExtractRepoFromConfig(ctx)
   378  	if err != nil {
   379  		return nil, err
   380  	}
   381  	if err := repo.CheckSCM(); err != nil {
   382  		return nil, err
   383  	}
   384  	return &scmChangeloger{
   385  		client: cli,
   386  		repo: client.Repo{
   387  			Owner: repo.Owner,
   388  			Name:  repo.Name,
   389  		},
   390  	}, nil
   391  }
   392  
   393  func loadContent(ctx *context.Context, fileName, tmplName string) (string, error) {
   394  	if tmplName != "" {
   395  		log.Debugf("loading template %q", tmplName)
   396  		content, err := loadFromFile(tmplName)
   397  		if err != nil {
   398  			return "", err
   399  		}
   400  		content, err = tmpl.New(ctx).Apply(content)
   401  		if strings.TrimSpace(content) == "" && err == nil {
   402  			log.Warnf("loaded %q, but it evaluates to an empty string", tmplName)
   403  		}
   404  		return content, err
   405  	}
   406  
   407  	if fileName != "" {
   408  		log.Debugf("loading file %q", fileName)
   409  		content, err := loadFromFile(fileName)
   410  		if strings.TrimSpace(content) == "" && err == nil {
   411  			log.Warnf("loaded %q, but it is empty", fileName)
   412  		}
   413  		return content, err
   414  	}
   415  
   416  	return "", nil
   417  }
   418  
   419  type changeloger interface {
   420  	Log(ctx *context.Context) (string, error)
   421  }
   422  
   423  type gitChangeloger struct{}
   424  
   425  var validSHA1 = regexp.MustCompile(`^[a-fA-F0-9]{40}$`)
   426  
   427  func (g gitChangeloger) Log(ctx *context.Context) (string, error) {
   428  	args := []string{"log", "--pretty=oneline", "--abbrev-commit", "--no-decorate", "--no-color"}
   429  	prev, current := comparePair(ctx)
   430  	if validSHA1.MatchString(prev) {
   431  		args = append(args, prev, current)
   432  	} else {
   433  		args = append(args, fmt.Sprintf("tags/%s..tags/%s", ctx.Git.PreviousTag, ctx.Git.CurrentTag))
   434  	}
   435  	return git.Run(ctx, args...)
   436  }
   437  
   438  type scmChangeloger struct {
   439  	client client.Client
   440  	repo   client.Repo
   441  }
   442  
   443  func (c *scmChangeloger) Log(ctx *context.Context) (string, error) {
   444  	prev, current := comparePair(ctx)
   445  	return c.client.Changelog(ctx, c.repo, prev, current)
   446  }
   447  
   448  type githubNativeChangeloger struct {
   449  	client client.ReleaseNotesGenerator
   450  	repo   client.Repo
   451  }
   452  
   453  func (c *githubNativeChangeloger) Log(ctx *context.Context) (string, error) {
   454  	return c.client.GenerateReleaseNotes(ctx, c.repo, ctx.Git.PreviousTag, ctx.Git.CurrentTag)
   455  }
   456  
   457  func comparePair(ctx *context.Context) (prev string, current string) {
   458  	prev = ctx.Git.PreviousTag
   459  	current = ctx.Git.CurrentTag
   460  	if prev == "" {
   461  		prev = ctx.Git.FirstCommit
   462  	}
   463  	return
   464  }