github.com/brandonmanuel/git-chglog@v0.0.0-20200903004639-7a62fa08787a/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  		for _, commit := range commits {
   192  			fmt.Println(commit.Body)
   193  		}
   194  
   195  		versions = append(versions, &Version{
   196  			Tag:           tag,
   197  			CommitGroups:  commitGroups,
   198  			Commits:       commits,
   199  			MergeCommits:  mergeCommits,
   200  			RevertCommits: revertCommits,
   201  			NoteGroups:    noteGroups,
   202  		})
   203  
   204  		// Instead of `getTags()`, assign the date to the tag
   205  		if isNext && len(commits) != 0 {
   206  			tag.Date = commits[0].Author.Date
   207  		}
   208  	}
   209  
   210  	return versions, nil
   211  }
   212  
   213  func (gen *Generator) readUnreleased(tags []*Tag) (*Unreleased, error) {
   214  	if gen.config.Options.NextTag != "" {
   215  		return &Unreleased{}, nil
   216  	}
   217  
   218  	rev := "HEAD"
   219  
   220  	if len(tags) > 0 {
   221  		rev = tags[0].Name + "..HEAD"
   222  	}
   223  
   224  	commits, err := gen.commitParser.Parse(rev)
   225  	if err != nil {
   226  		return nil, err
   227  	}
   228  
   229  	commitGroups, mergeCommits, revertCommits, noteGroups := gen.commitExtractor.Extract(commits)
   230  
   231  	unreleased := &Unreleased{
   232  		CommitGroups:  commitGroups,
   233  		Commits:       commits,
   234  		MergeCommits:  mergeCommits,
   235  		RevertCommits: revertCommits,
   236  		NoteGroups:    noteGroups,
   237  	}
   238  
   239  	return unreleased, nil
   240  }
   241  
   242  func (gen *Generator) getTags(query string) ([]*Tag, string, error) {
   243  	tags, err := gen.tagReader.ReadAll()
   244  	if err != nil {
   245  		return nil, "", err
   246  	}
   247  
   248  	next := gen.config.Options.NextTag
   249  	if next != "" {
   250  		for _, tag := range tags {
   251  			if next == tag.Name {
   252  				return nil, "", fmt.Errorf("\"%s\" tag already exists", next)
   253  			}
   254  		}
   255  
   256  		var previous *RelateTag
   257  		if len(tags) > 0 {
   258  			previous = &RelateTag{
   259  				Name:    tags[0].Name,
   260  				Subject: tags[0].Subject,
   261  				Date:    tags[0].Date,
   262  			}
   263  		}
   264  
   265  		// Assign the date with `readVersions()`
   266  		tags = append([]*Tag{
   267  			&Tag{
   268  				Name:     next,
   269  				Subject:  next,
   270  				Previous: previous,
   271  			},
   272  		}, tags...)
   273  	}
   274  
   275  	if len(tags) == 0 {
   276  		return nil, "", errors.New("git-tag does not exist")
   277  	}
   278  
   279  	first := ""
   280  	if query != "" {
   281  		tags, first, err = gen.tagSelector.Select(tags, query)
   282  		if err != nil {
   283  			return nil, "", err
   284  		}
   285  	}
   286  
   287  	return tags, first, nil
   288  }
   289  
   290  func (gen *Generator) workdir() (func() error, error) {
   291  	cwd, err := os.Getwd()
   292  	if err != nil {
   293  		return nil, err
   294  	}
   295  
   296  	err = os.Chdir(gen.config.WorkingDir)
   297  	if err != nil {
   298  		return nil, err
   299  	}
   300  
   301  	return func() error {
   302  		return os.Chdir(cwd)
   303  	}, nil
   304  }
   305  
   306  func (gen *Generator) render(w io.Writer, unreleased *Unreleased, versions []*Version) error {
   307  	if _, err := os.Stat(gen.config.Template); err != nil {
   308  		return err
   309  	}
   310  
   311  	fmap := template.FuncMap{
   312  		// format the input time according to layout
   313  		"datetime": func(layout string, input time.Time) string {
   314  			return input.Format(layout)
   315  		},
   316  		// check whether substs is withing s
   317  		"contains": func(s, substr string) bool {
   318  			return strings.Contains(s, substr)
   319  		},
   320  		// check whether s begins with prefix
   321  		"hasPrefix": func(s, prefix string) bool {
   322  			return strings.HasPrefix(s, prefix)
   323  		},
   324  		// check whether s ends with suffix
   325  		"hasSuffix": func(s, suffix string) bool {
   326  			return strings.HasSuffix(s, suffix)
   327  		},
   328  		// replace the first n instances of old with new
   329  		"replace": func(s, old, new string, n int) string {
   330  			return strings.Replace(s, old, new, n)
   331  		},
   332  		// lower case a string
   333  		"lower": func(s string) string {
   334  			return strings.ToLower(s)
   335  		},
   336  		// upper case a string
   337  		"upper": func(s string) string {
   338  			return strings.ToUpper(s)
   339  		},
   340  		// upper case the first character of a string
   341  		"upperFirst": func(s string) string {
   342  			if len(s) > 0 {
   343  				return strings.ToUpper(string(s[0])) + s[1:]
   344  			}
   345  			return ""
   346  		},
   347  	}
   348  
   349  	fname := filepath.Base(gen.config.Template)
   350  
   351  	t := template.Must(template.New(fname).Funcs(fmap).ParseFiles(gen.config.Template))
   352  
   353  	return t.Execute(w, &RenderData{
   354  		Info:       gen.config.Info,
   355  		Unreleased: unreleased,
   356  		Versions:   versions,
   357  	})
   358  }