github.com/rivy-go/git-changelog@v0.0.0-20240424224517-b86e6ab57773/internal/changelog/changelog.go (about)

     1  // Package changelog implements main logic for the CHANGELOG generate.
     2  package changelog
     3  
     4  import (
     5  	"fmt"
     6  	"io"
     7  	"os"
     8  	"path/filepath"
     9  	"regexp"
    10  	"strings"
    11  	"text/template"
    12  	"time"
    13  	"unicode"
    14  
    15  	gitcmd "github.com/tsuyoshiwada/go-gitcmd"
    16  )
    17  
    18  // Options is an option used to process commits
    19  type Options struct {
    20  	Processor            Processor
    21  	ShowUnreleased       bool                // Enable processing of unreleased commits
    22  	NextTag              string              // Treat unreleased commits as specified tags (EXPERIMENTAL)
    23  	NextTagNow           bool                // Assign date of 'next-tag' to current time (EXPERIMENTAL)
    24  	TagFilterPattern     string              // Filter tag by regexp
    25  	NoCaseSensitive      bool                // Filter commits in a case insensitive way
    26  	CommitFilters        map[string][]string // Filter by using `Commit` properties and values. Filtering is not done by specifying an empty value
    27  	CommitSortBy         string              // Property name to use for sorting `Commit` (e.g. `Scope`)
    28  	CommitTypeMaps       map[string]string   // Map to aggregate commit types (e.g., fixed => fix)
    29  	CommitGroupBy        string              // Property name of `Commit` to be grouped into `CommitGroup` (e.g. `Type`)
    30  	CommitGroupSortBy    string              // Property name to use for sorting `CommitGroup` (e.g. `Title`)
    31  	CommitGroupTitleMaps map[string]string   // Map for `CommitGroup` title conversion
    32  	HeaderPattern        string              // A regular expression to use for parsing the commit header
    33  	HeaderPatternMaps    []string            // A rule for mapping the result of `HeaderPattern` to the property of `Commit`
    34  	IssuePrefix          []string            // Prefix used for issues (e.g. `#`, `gh-`)
    35  	RefActions           []string            // Word list of `Ref.Action`
    36  	MergePattern         string              // A regular expression to use for parsing the merge commit
    37  	MergePatternMaps     []string            // Similar to `HeaderPatternMaps`
    38  	RevertPattern        string              // A regular expression to use for parsing the revert commit
    39  	RevertPatternMaps    []string            // Similar to `HeaderPatternMaps`
    40  	NoteKeywords         []string            // Keyword list to find `Note`. A semicolon is a separator, like `<keyword>:` (e.g. `BREAKING CHANGE`)
    41  }
    42  
    43  // Info is metadata related to CHANGELOG
    44  type Info struct {
    45  	Title         string // Title of CHANGELOG
    46  	RepositoryURL string // URL of git repository
    47  }
    48  
    49  // RenderData is the data passed to the template
    50  type RenderData struct {
    51  	Info       *Info
    52  	Unreleased *Unreleased
    53  	Versions   []*Version
    54  }
    55  
    56  // Config for generating CHANGELOG
    57  type Config struct {
    58  	Bin        string // Git execution command
    59  	WorkingDir string // Working directory
    60  	Style      string // repository style (eg, "bitbucket", "github", "gitlab", ...)
    61  	Template   string // Path for template file. If a relative path is specified, it depends on the value of `WorkingDir`.
    62  	Info       *Info
    63  	Options    *Options
    64  }
    65  
    66  func normalizeConfig(config *Config) {
    67  	opts := config.Options
    68  
    69  	if opts.HeaderPattern == "" {
    70  		opts.HeaderPattern = "^(.*)$"
    71  		opts.HeaderPatternMaps = []string{
    72  			"Subject",
    73  		}
    74  	}
    75  
    76  	if opts.MergePattern == "" {
    77  		opts.MergePattern = "^Merge branch '(\\w+)'$"
    78  		opts.MergePatternMaps = []string{
    79  			"Source",
    80  		}
    81  	}
    82  
    83  	if opts.RevertPattern == "" {
    84  		opts.RevertPattern = "^Revert \"([\\s\\S]*)\"$"
    85  		opts.RevertPatternMaps = []string{
    86  			"Header",
    87  		}
    88  	}
    89  
    90  	config.Options = opts
    91  }
    92  
    93  // Generator of CHANGELOG
    94  type Generator struct {
    95  	client          gitcmd.Client
    96  	config          *Config
    97  	tagReader       *tagReader
    98  	tagSelector     *tagSelector
    99  	commitParser    *commitParser
   100  	commitExtractor *commitExtractor
   101  }
   102  
   103  // NewGenerator receives `Config` and create an new `Generator`
   104  func NewGenerator(config *Config) *Generator {
   105  	client := gitcmd.New(&gitcmd.Config{
   106  		Bin: config.Bin,
   107  	})
   108  
   109  	if config.Options.Processor != nil {
   110  		config.Options.Processor.Bootstrap(config)
   111  	}
   112  
   113  	normalizeConfig(config)
   114  
   115  	return &Generator{
   116  		client:          client,
   117  		config:          config,
   118  		tagReader:       newTagReader(client, config.Options.TagFilterPattern),
   119  		tagSelector:     newTagSelector(),
   120  		commitParser:    newCommitParser(client, config),
   121  		commitExtractor: newCommitExtractor(config.Options),
   122  	}
   123  }
   124  
   125  // Generate gets the commit based on the specified tag `query` and writes the result to `io.Writer`
   126  //
   127  // `query` can be specified in the following ways:
   128  //
   129  //	<old>..<new> - Commit contained in '<new>' tags from '<old>' (e.g. `1.0.0..2.0.0`)
   130  //	<tagname>..  - Commit from the '<tagname>' to the latest tag (e.g. `1.0.0..`)
   131  //	..<tagname>  - Commit from the oldest tag to '<tagname>' (e.g. `..1.0.0`)
   132  //	<tagname>    - Commit contained in '<tagname>' (e.g. `1.0.0`)
   133  func (gen *Generator) Generate(w io.Writer, query string) error {
   134  	back, err := gen.workdir()
   135  	if err != nil {
   136  		return err
   137  	}
   138  	defer back()
   139  
   140  	tags, first, err := gen.getTags(query)
   141  	if err != nil {
   142  		return err
   143  	}
   144  
   145  	unreleased, err := gen.readUnreleased(tags)
   146  	if err != nil {
   147  		return err
   148  	}
   149  
   150  	versions, err := gen.readVersions(tags, first)
   151  	if err != nil {
   152  		return err
   153  	}
   154  
   155  	if query != "" && len(versions) == 0 {
   156  		return fmt.Errorf("commits corresponding to \"%s\" was not found", query)
   157  	}
   158  
   159  	return gen.render(w, unreleased, versions)
   160  }
   161  
   162  func (gen *Generator) readVersions(tags []*Tag, first string) ([]*Version, error) {
   163  	next := gen.config.Options.NextTag
   164  	versions := []*Version{}
   165  
   166  	for i, tag := range tags {
   167  		var (
   168  			isNext = next == tag.Name
   169  			rev    string
   170  		)
   171  
   172  		if isNext {
   173  			if tag.Previous != nil {
   174  				rev = tag.Previous.Name + "..HEAD"
   175  			} else {
   176  				rev = "HEAD"
   177  			}
   178  		} else {
   179  			if i+1 < len(tags) {
   180  				rev = tags[i+1].Name + ".." + tag.Name
   181  			} else {
   182  				if first != "" {
   183  					rev = first + ".." + tag.Name
   184  				} else {
   185  					rev = tag.Name
   186  				}
   187  			}
   188  		}
   189  
   190  		commits, err := gen.commitParser.Parse(rev)
   191  		if err != nil {
   192  			return nil, err
   193  		}
   194  
   195  		commitGroups, mergeCommits, revertCommits, noteGroups := gen.commitExtractor.Extract(commits)
   196  
   197  		versions = append(versions, &Version{
   198  			Tag:           tag,
   199  			CommitGroups:  commitGroups,
   200  			Commits:       commits,
   201  			MergeCommits:  mergeCommits,
   202  			RevertCommits: revertCommits,
   203  			NoteGroups:    noteGroups,
   204  		})
   205  
   206  		// Instead of `getTags()`, assign the date to the tag
   207  		if isNext && len(commits) != 0 {
   208  			if gen.config.Options.NextTagNow {
   209  				tag.Date = time.Now()
   210  			} else {
   211  				tag.Date = commits[0].Author.Date
   212  			}
   213  		}
   214  	}
   215  
   216  	return versions, nil
   217  }
   218  
   219  func (gen *Generator) readUnreleased(tags []*Tag) (*Unreleased, error) {
   220  	if (!gen.config.Options.ShowUnreleased) || (gen.config.Options.NextTag != "") {
   221  		return &Unreleased{}, nil
   222  	}
   223  
   224  	rev := "HEAD"
   225  
   226  	if len(tags) > 0 {
   227  		rev = tags[0].Name + "..HEAD"
   228  	}
   229  
   230  	commits, err := gen.commitParser.Parse(rev)
   231  	if err != nil {
   232  		return nil, err
   233  	}
   234  
   235  	commitGroups, mergeCommits, revertCommits, noteGroups := gen.commitExtractor.Extract(commits)
   236  
   237  	unreleased := &Unreleased{
   238  		CommitGroups:  commitGroups,
   239  		Commits:       commits,
   240  		MergeCommits:  mergeCommits,
   241  		RevertCommits: revertCommits,
   242  		NoteGroups:    noteGroups,
   243  	}
   244  
   245  	return unreleased, nil
   246  }
   247  
   248  func (gen *Generator) getTags(query string) ([]*Tag, string, error) {
   249  	tags, err := gen.tagReader.ReadAll()
   250  	if err != nil {
   251  		return nil, "", err
   252  	}
   253  
   254  	next := gen.config.Options.NextTag
   255  	if next != "" {
   256  		for _, tag := range tags {
   257  			if next == tag.Name {
   258  				return nil, "", fmt.Errorf("\"%s\" tag already exists", next)
   259  			}
   260  		}
   261  
   262  		var previous *RelateTag
   263  		if len(tags) > 0 {
   264  			previous = &RelateTag{
   265  				Name:    tags[0].Name,
   266  				Subject: tags[0].Subject,
   267  				Date:    tags[0].Date,
   268  			}
   269  		}
   270  
   271  		// Assign the date with `readVersions()`
   272  		tags = append([]*Tag{
   273  			&Tag{
   274  				Name:     next,
   275  				Subject:  next,
   276  				Previous: previous,
   277  			},
   278  		}, tags...)
   279  	}
   280  
   281  	first := ""
   282  	if query != "" {
   283  		tags, first, err = gen.tagSelector.Select(tags, query)
   284  		if err != nil {
   285  			return nil, "", err
   286  		}
   287  	}
   288  
   289  	return tags, first, nil
   290  }
   291  
   292  func (gen *Generator) workdir() (func() error, error) {
   293  	cwd, err := os.Getwd()
   294  	if err != nil {
   295  		return nil, err
   296  	}
   297  
   298  	err = os.Chdir(gen.config.WorkingDir)
   299  	if err != nil {
   300  		return nil, err
   301  	}
   302  
   303  	return func() error {
   304  		return os.Chdir(cwd)
   305  	}, nil
   306  }
   307  
   308  func (gen *Generator) render(w io.Writer, unreleased *Unreleased, versions []*Version) error {
   309  	if _, err := os.Stat(gen.config.Template); err != nil {
   310  		return err
   311  	}
   312  
   313  	fmap := template.FuncMap{
   314  		// format commit hash URL based on repository style
   315  		"commitURL": func(hash string) string {
   316  			if gen.config.Style == "bitbucket" {
   317  				return fmt.Sprintf("%s/commits/%s", gen.config.Info.RepositoryURL, hash)
   318  			}
   319  			// default to 'github'/'gitlab' style
   320  			return fmt.Sprintf("%s/commit/%s", gen.config.Info.RepositoryURL, hash)
   321  		},
   322  		// format the input time according to layout
   323  		"datetime": func(layout string, input time.Time) string {
   324  			return input.Format(layout)
   325  		},
   326  		// check whether substs is withing s
   327  		"contains": func(s, substr string) bool {
   328  			return strings.Contains(s, substr)
   329  		},
   330  		// check whether s begins with prefix
   331  		"hasPrefix": func(s, prefix string) bool {
   332  			return strings.HasPrefix(s, prefix)
   333  		},
   334  		// check whether s ends with suffix
   335  		"hasSuffix": func(s, suffix string) bool {
   336  			return strings.HasSuffix(s, suffix)
   337  		},
   338  		// replace the first n instances of old with new
   339  		"replace": func(s, old, new string, n int) string {
   340  			return strings.Replace(s, old, new, n)
   341  		},
   342  		// lower case a string
   343  		"lower": func(s string) string {
   344  			return strings.ToLower(s)
   345  		},
   346  		// upper case a string
   347  		"upper": func(s string) string {
   348  			return strings.ToUpper(s)
   349  		},
   350  		// upper case first non-space character of string
   351  		"upperFirst": func(s string) string {
   352  			var retval = s
   353  			var rx = regexp.MustCompile("^(\\s*)(.)(\\w*)(.*)$")
   354  			var matches = rx.FindStringSubmatch(s)
   355  			if len(matches) > 1 {
   356  				retval = matches[1] + strings.ToUpper(matches[2]) + strings.ToLower(matches[3]) + matches[4]
   357  			}
   358  			return retval
   359  		},
   360  		// lower case first word of string; "smart" == leave unchanged any word which has two+ upper case characters
   361  		"smartLowerFirstWord": func(s string) string {
   362  			var retval = s
   363  			var rx = regexp.MustCompile("^(\\s*)(\\w+)(.*)$")
   364  			var matches = rx.FindStringSubmatch(s)
   365  			if len(matches) > 1 {
   366  				var word = matches[2]
   367  				var upperN = 0
   368  				for _, rune := range word {
   369  					if rune == unicode.ToUpper(rune) {
   370  						upperN++
   371  					}
   372  				}
   373  				if upperN < 2 {
   374  					word = strings.ToLower(word)
   375  				}
   376  				retval = matches[1] + word + matches[3]
   377  			}
   378  			return retval
   379  		},
   380  	}
   381  
   382  	fname := filepath.Base(gen.config.Template)
   383  
   384  	t := template.Must(template.New(fname).Funcs(fmap).ParseFiles(gen.config.Template))
   385  
   386  	return t.Execute(w, &RenderData{
   387  		Info:       gen.config.Info,
   388  		Unreleased: unreleased,
   389  		Versions:   versions,
   390  	})
   391  }