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 }