// Copyright 2017-present The Hugo Authors. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package releaser import ( "fmt" "regexp" "sort" "strconv" "strings" "github.com/gohugoio/hugo/common/hexec" ) var issueRe = regexp.MustCompile(`(?i)(?:Updates?|Closes?|Fix.*|See) #(\d+)`) const ( notesChanges = "notesChanges" templateChanges = "templateChanges" coreChanges = "coreChanges" outChanges = "outChanges" otherChanges = "otherChanges" ) type changeLog struct { Version string Enhancements map[string]gitInfos Fixes map[string]gitInfos Notes gitInfos All gitInfos Docs gitInfos // Overall stats Repo *gitHubRepo ContributorCount int ThemeCount int } func newChangeLog(infos, docInfos gitInfos) *changeLog { return &changeLog{ Enhancements: make(map[string]gitInfos), Fixes: make(map[string]gitInfos), All: infos, Docs: docInfos, } } func (l *changeLog) addGitInfo(isFix bool, info gitInfo, category string) { var ( infos gitInfos found bool segment map[string]gitInfos ) if category == notesChanges { l.Notes = append(l.Notes, info) return } else if isFix { segment = l.Fixes } else { segment = l.Enhancements } infos, found = segment[category] if !found { infos = gitInfos{} } infos = append(infos, info) segment[category] = infos } func gitInfosToChangeLog(infos, docInfos gitInfos) *changeLog { log := newChangeLog(infos, docInfos) for _, info := range infos { los := strings.ToLower(info.Subject) isFix := strings.Contains(los, "fix") category := otherChanges // TODO(bep) improve if regexp.MustCompile("(?i)deprecate").MatchString(los) { category = notesChanges } else if regexp.MustCompile("(?i)tpl|tplimpl:|layout").MatchString(los) { category = templateChanges } else if regexp.MustCompile("(?i)hugolib:").MatchString(los) { category = coreChanges } else if regexp.MustCompile("(?i)out(put)?:|media:|Output|Media").MatchString(los) { category = outChanges } // Trim package prefix. colonIdx := strings.Index(info.Subject, ":") if colonIdx != -1 && colonIdx < (len(info.Subject)/2) { info.Subject = info.Subject[colonIdx+1:] } info.Subject = strings.TrimSpace(info.Subject) log.addGitInfo(isFix, info, category) } return log } type gitInfo struct { Hash string Author string Subject string Body string GitHubCommit *gitHubCommit } func (g gitInfo) Issues() []int { return extractIssues(g.Body) } func (g gitInfo) AuthorID() string { if g.GitHubCommit != nil { return g.GitHubCommit.Author.Login } return g.Author } func extractIssues(body string) []int { var i []int m := issueRe.FindAllStringSubmatch(body, -1) for _, mm := range m { issueID, err := strconv.Atoi(mm[1]) if err != nil { continue } i = append(i, issueID) } return i } type gitInfos []gitInfo func git(args ...string) (string, error) { cmd, _ := hexec.SafeCommand("git", args...) out, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("git failed: %q: %q (%q)", err, out, args) } return string(out), nil } func getGitInfos(tag, repo, repoPath string, remote bool) (gitInfos, error) { return getGitInfosBefore("HEAD", tag, repo, repoPath, remote) } type countribCount struct { Author string GitHubAuthor gitHubAuthor Count int } func (c countribCount) AuthorLink() string { if c.GitHubAuthor.HTMLURL != "" { return fmt.Sprintf("[@%s](%s)", c.GitHubAuthor.Login, c.GitHubAuthor.HTMLURL) } if !strings.Contains(c.Author, "@") { return c.Author } return c.Author[:strings.Index(c.Author, "@")] } type contribCounts []countribCount func (c contribCounts) Less(i, j int) bool { return c[i].Count > c[j].Count } func (c contribCounts) Len() int { return len(c) } func (c contribCounts) Swap(i, j int) { c[i], c[j] = c[j], c[i] } func (g gitInfos) ContribCountPerAuthor() contribCounts { var c contribCounts counters := make(map[string]countribCount) for _, gi := range g { authorID := gi.AuthorID() if count, ok := counters[authorID]; ok { count.Count = count.Count + 1 counters[authorID] = count } else { var ghA gitHubAuthor if gi.GitHubCommit != nil { ghA = gi.GitHubCommit.Author } authorCount := countribCount{Count: 1, Author: gi.Author, GitHubAuthor: ghA} counters[authorID] = authorCount } } for _, v := range counters { c = append(c, v) } sort.Sort(c) return c } func getGitInfosBefore(ref, tag, repo, repoPath string, remote bool) (gitInfos, error) { client := newGitHubAPI(repo) var g gitInfos log, err := gitLogBefore(ref, tag, repoPath) if err != nil { return g, err } log = strings.Trim(log, "\n\x1e'") entries := strings.Split(log, "\x1e") for _, entry := range entries { items := strings.Split(entry, "\x1f") gi := gitInfo{} if len(items) > 0 { gi.Hash = items[0] } if len(items) > 1 { gi.Author = items[1] } if len(items) > 2 { gi.Subject = items[2] } if len(items) > 3 { gi.Body = items[3] } if remote && gi.Hash != "" { gc, err := client.fetchCommit(gi.Hash) if err == nil { gi.GitHubCommit = &gc } } g = append(g, gi) } return g, nil } // Ignore autogenerated commits etc. in change log. This is a regexp. const ignoredCommits = "releaser?:|snapcraft:|Merge commit|Squashed" func gitLogBefore(ref, tag, repoPath string) (string, error) { var prevTag string var err error if tag != "" { prevTag = tag } else { prevTag, err = gitVersionTagBefore(ref) if err != nil { return "", err } } defaultArgs := []string{"log", "-E", fmt.Sprintf("--grep=%s", ignoredCommits), "--invert-grep", "--pretty=format:%x1e%h%x1f%aE%x1f%s%x1f%b", "--abbrev-commit", prevTag + ".." + ref} var args []string if repoPath != "" { args = append([]string{"-C", repoPath}, defaultArgs...) } else { args = defaultArgs } log, err := git(args...) if err != nil { return ",", err } return log, err } func gitVersionTagBefore(ref string) (string, error) { return gitShort("describe", "--tags", "--abbrev=0", "--always", "--match", "v[0-9]*", ref+"^") } func gitShort(args ...string) (output string, err error) { output, err = git(args...) return strings.Replace(strings.Split(output, "\n")[0], "'", "", -1), err } func tagExists(tag string) (bool, error) { out, err := git("tag", "-l", tag) if err != nil { return false, err } if strings.Contains(out, tag) { return true, nil } return false, nil }