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