| // Copyright 2021 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| // The relnotes command summarizes the Go changes in Gerrit marked with |
| // RELNOTE annotations for the release notes. |
| package main |
| |
| import ( |
| "bytes" |
| "context" |
| "flag" |
| "fmt" |
| "io/ioutil" |
| "log" |
| "path/filepath" |
| "regexp" |
| "sort" |
| "strings" |
| "time" |
| "unicode" |
| |
| "github.com/stamblerre/work-stats/generic" |
| "github.com/stamblerre/work-stats/golang" |
| "golang.org/x/build/maintner" |
| "golang.org/x/build/maintner/godata" |
| ) |
| |
| var ( |
| milestone = flag.String("milestone", "", "milestone associated with the release") |
| filterDirs = flag.String("dirs", "", "comma-separated list of directories that should be touched for a CL to be considered relevant") |
| sinceCL = flag.Int("cl", -1, "the gerrit change number of the first CL to include in the output. Only changes submitted more recently than 'cl' will be included.") |
| project = flag.String("project", "vscode-go", "name of the golang project") |
| mdMode = flag.Bool("md", false, "write MD output") |
| exclFile = flag.String("exclude-from", "", "optional path to changelog MD file. If specified, any 'CL NNNN' occurence in the content will cause that CL to be excluded from this tool's output.") |
| ) |
| |
| func main() { |
| flag.Parse() |
| |
| var existingMD []byte |
| if *exclFile != "" { |
| var err error |
| existingMD, err = ioutil.ReadFile(*exclFile) |
| if err != nil { |
| log.Fatal(err) |
| } |
| } |
| |
| corpus, err := godata.Get(context.Background()) |
| if err != nil { |
| log.Fatal(err) |
| } |
| |
| var dirs []string |
| for _, dir := range strings.FieldsFunc(*filterDirs, func(r rune) bool { |
| return unicode.IsSpace(r) || r == ',' |
| }) { |
| dirs = append(dirs, filepath.ToSlash(dir)) |
| } |
| |
| ger := corpus.Gerrit() |
| |
| // Find the cutoff time for changes to include. |
| start := time.Date(2020, time.August, 1, 00, 00, 00, 0, time.UTC) |
| ger.ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { |
| if gp.Server() != "go.googlesource.com" || gp.Project() != *project { |
| return nil |
| } |
| gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error { |
| if cl.Status != "merged" { |
| return nil |
| } |
| if *sinceCL >= 0 { |
| if int(cl.Number) == *sinceCL { |
| start = cl.Commit.CommitTime |
| } |
| } else if cl.Branch() == "release" && cl.Commit.CommitTime.After(start) { |
| // Try to figure out when the last release was |
| fmt.Println(cl.Commit.CommitTime) |
| start = cl.Commit.CommitTime |
| } |
| return nil |
| }) |
| return nil |
| }) |
| |
| var changes []*generic.Changelist |
| authors := map[*maintner.GitPerson]bool{} |
| ger.ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { |
| if gp.Server() != "go.googlesource.com" || gp.Project() != *project { |
| return nil |
| } |
| gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error { |
| // Only include 'master' |
| if cl.Branch() != "master" { |
| return nil |
| } |
| if cl.Status != "merged" { |
| return nil |
| } |
| if cl.Commit.CommitTime.Before(start) { |
| // Was in a previous release; not for this one. |
| return nil |
| } |
| |
| if bytes.Contains(existingMD, []byte(fmt.Sprintf("CL %d ", cl.Number))) { |
| return nil |
| } |
| |
| // Check that at least one file is in a relevant directory before |
| // adding the CL. |
| if len(dirs) > 0 { |
| var found bool |
| for _, file := range cl.Commit.Files { |
| for _, dir := range dirs { |
| if strings.Contains(file.File, dir) { |
| found = true |
| break |
| } |
| } |
| } |
| if !found { |
| return nil |
| } |
| } |
| changes = append(changes, golang.GerritToGenericCL(cl)) |
| authors[cl.Owner()] = true |
| return nil |
| }) |
| return nil |
| }) |
| |
| sort.Slice(changes, func(i, j int) bool { |
| return changes[i].Number < changes[j].Number |
| }) |
| |
| if *mdMode { |
| fmt.Printf("## TODO: version - ") |
| now := time.Now() |
| fmt.Printf("%s\n\n", now.Format("2 Jan, 2006")) |
| fmt.Printf("### Changes\n\n") |
| mdPrintChanges(changes, true) |
| |
| fmt.Printf("### Issues\n\n") |
| mdPrintIssues(changes, *milestone) |
| |
| fmt.Printf("\n### Thanks\n\n") |
| mdPrintContributors(authors) |
| } else { |
| for _, change := range changes { |
| fmt.Printf(" %s\n", change.Subject) |
| } |
| } |
| } |
| |
| func mdPrintChanges(changes []*generic.Changelist, byCategory bool) { |
| printChange := func(change *generic.Changelist) { |
| fmt.Printf("- ") |
| content := change.Subject |
| note := releaseNote(change) |
| if note != "" && note != "yes" && note != "y" { |
| // The release note contains content. |
| content = note |
| } |
| |
| fmt.Printf("%s", content) |
| if len(change.AssociatedIssues) > 0 { |
| fmt.Printf(" (") |
| for i, issue := range change.AssociatedIssues { |
| if i == 0 { |
| fmt.Printf("[Issue %d](https://%s)", issue.Number, issue.Link) |
| } else { |
| fmt.Printf(", [%d](https://%s)", issue.Number, issue.Link) |
| } |
| } |
| fmt.Printf(")") |
| } |
| fmt.Printf(" <!-- CL %d -->\n", change.Number) |
| } |
| // Group CLs by category or by number order. |
| if byCategory { |
| pkgMap := map[string][]*generic.Changelist{} |
| for _, change := range changes { |
| pkgMap[change.Category()] = append(pkgMap[change.Category()], change) |
| } |
| for _, changes := range pkgMap { |
| for _, change := range changes { |
| printChange(change) |
| } |
| } |
| } else { |
| for _, change := range changes { |
| printChange(change) |
| } |
| } |
| } |
| |
| func mdPrintIssues(changes []*generic.Changelist, milestone string) { |
| var issues []*generic.Issue |
| for _, change := range changes { |
| issues = append(issues, change.AssociatedIssues...) |
| } |
| sort.Slice(issues, func(i, j int) bool { |
| return issues[i].Link < issues[j].Link |
| }) |
| for _, issue := range issues { |
| if !issue.Closed() { |
| continue |
| } |
| fmt.Printf("%s: %s\n", issue.Link, issue.Milestone) |
| } |
| } |
| |
| // clPackage returns the package name from the CL's commit message, |
| // or "??" if it's formatted unconventionally. |
| func clPackage(cl *maintner.GerritCL) string { |
| subj := cl.Subject() |
| if i := strings.Index(subj, ":"); i != -1 { |
| return subj[:i] |
| } |
| return "??" |
| } |
| |
| var relNoteRx = regexp.MustCompile(`RELNOTES?=(.+)`) |
| |
| func parseRelNote(s string) string { |
| if m := relNoteRx.FindStringSubmatch(s); m != nil { |
| return m[1] |
| } |
| return "" |
| } |
| |
| func releaseNote(cl *generic.Changelist) string { |
| if strings.Contains(cl.Message, "RELNOTE") { |
| return parseRelNote(cl.Message) |
| } |
| for _, comment := range cl.Comments { |
| if strings.Contains(comment, "RELNOTE") { |
| return parseRelNote(comment) |
| } |
| } |
| return "" |
| } |
| |
| func mdPrintContributors(authors map[*maintner.GitPerson]bool) { |
| var names []string |
| for author := range authors { |
| // It would be great to look up the GitHub username by using: |
| // https://pkg.go.dev/golang.org/x/build/internal/gophers#GetPerson. |
| names = append(names, author.Name()) |
| } |
| sort.Strings(names) |
| if len(names) > 1 { |
| names[len(names)-1] = "and " + names[len(names)-1] |
| } |
| |
| fmt.Printf("Thank you for your contribution, %s!\n", strings.Join(names, ", ")) |
| } |