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