github.com/Trim21/git-chglog@v0.0.0-20200414013904-db796966b373/chglog.go (about)

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