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