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 }