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