tools/relnotes/relnotes.go (337 lines of code) (raw):

// 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" "encoding/json" "flag" "fmt" "io/ioutil" "log" "net/http" "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") exclFile = flag.String("exclude-from", "", "optional path to changelog MD file. If specified, any 'CL NNNN' occurrence in the content will cause that CL to be excluded from this tool's output.") semanticVersion = flag.String("semver", "", "the semantic version of the new release") githubTokenFilePath = flag.String("token", "", "the absolute path to the github token file") ) func main() { flag.Parse() if *semanticVersion == "" { log.Fatal("Must provide -semver.") } if *githubTokenFilePath == "" { log.Fatal("Must provide -token.") } 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 cls := map[*maintner.GerritCL]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 } } if isGoplsChangeList(golang.GerritToGenericCL(cl)) { changes = append(changes, golang.GerritToGenericCL(cl)) cls[cl] = true } return nil }) return nil }) fmt.Printf("# Version: %s\n\n", *semanticVersion) fmt.Printf("## TODO: version - ") now := time.Now() fmt.Printf("%s\n\n", now.Format("2 Jan, 2006")) fmt.Printf("### Changes\n\n") mdPrintChanges(changes, false) fmt.Printf("\n\n") fmt.Printf("### Issues\n\n") mdPrintIssues(changes, *milestone) fmt.Printf("\n\n") fmt.Printf("### Release comments\n\n") mdPrintReleaseComments(changes) fmt.Printf("\n\n") fmt.Printf("\n### Thanks\n\n") mdPrintContributors(cls) } func isGoplsChangeList(cl *generic.Changelist) bool { if strings.Contains(cl.Subject, "internal/lsp") || strings.Contains(cl.Subject, "gopls") { return true } for _, issue := range cl.AssociatedIssues { if issue.Repo == "golang/vscode-go" { return true } for _, label := range issue.Labels { if label == "gopls" { return true } } } return false } 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 first associated issue number. 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 { sort.Slice(changes, func(i, j int) bool { // Sort first by associated issue, then by CL number. var iIssue, jIssue int // first associated issues if len(changes[i].AssociatedIssues) > 0 { iIssue = changes[i].AssociatedIssues[0].Number } if len(changes[j].AssociatedIssues) > 0 { jIssue = changes[j].AssociatedIssues[0].Number } if iIssue != 0 && jIssue != 0 { return iIssue < jIssue // sort CLs with issues first } return iIssue != 0 || changes[i].Number < changes[j].Number }) currentChange := -1 for i, change := range changes { if len(change.AssociatedIssues) > 0 && change.AssociatedIssues[0].Number != currentChange { currentChange = change.AssociatedIssues[0].Number fmt.Printf("CL(s) for issue %d:\n", currentChange) } else if len(change.AssociatedIssues) == 0 && (i == 0 || len(changes[i-1].AssociatedIssues) > 0) { fmt.Printf("CL(s) not associated with any issue:\n") } 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) } } func mdPrintReleaseComments(changes []*generic.Changelist) { type Issue struct { repo string number int } printedIssues := make(map[Issue]bool) for _, change := range changes { for _, issue := range change.AssociatedIssues { if _, ok := printedIssues[Issue{issue.Repo, issue.Number}]; !ok { printedIssues[Issue{issue.Repo, issue.Number}] = true printIssueReleaseComment(issue.Repo, issue.Number) } } } } 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(cls map[*maintner.GerritCL]bool) { var usernames []string for changelist := range cls { author, err := fetchCLAuthorName(changelist, *project) if err != nil { log.Fatalf("Error fetching Github information for %s: %v\n", changelist.Owner(), err) } usernames = append(usernames, author) } usernames = unique(usernames) if len(usernames) > 1 { usernames[len(usernames)-1] = "and " + usernames[len(usernames)-1] } fmt.Printf("Thank you for your contribution, %s!\n", strings.Join(usernames, ", ")) } func getURL(url string) ([]byte, error) { req, _ := http.NewRequest("GET", url, nil) if token, err := ioutil.ReadFile(*githubTokenFilePath); err == nil { req.Header.Set("Authorization", "token "+strings.TrimSpace(string(token))) } res, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer res.Body.Close() body, err := ioutil.ReadAll(res.Body) if err != nil { log.Fatalf("Error fetching Github information at %s: %v\n", url, err) } return body, nil } func fetchCLAuthorName(changelist *maintner.GerritCL, repo string) (string, error) { githubRepoMapping := map[string]string{ "tools": "golang/tools", "vscode-go": "golang/vscode-go", } body, err := getURL(fmt.Sprintf("https://api.github.com/repos/%s/commits/%s", githubRepoMapping[repo], changelist.Commit.Hash)) if err != nil { return "", err } var resp map[string]interface{} if err := json.Unmarshal(body, &resp); err != nil { return "", err } if authorInfo, _ := resp["author"].(map[string]interface{}); authorInfo != nil { if username, ok := authorInfo["login"].(string); ok { return "@" + username, nil } } return changelist.Owner().Name(), nil } // printIssueReleaseComment collects the release comments, which marked by the annotation *Release*, from the issues included in this release. func printIssueReleaseComment(repo string, issueNumber int) { body, err := getURL(fmt.Sprintf("https://api.github.com/repos/%s/issues/%d/comments", repo, issueNumber)) if err != nil { log.Fatal(err) } var issueComments []interface{} if err := json.Unmarshal(body, &issueComments); err != nil { log.Fatalf("Error fetching Github information for issue %d:\n", issueNumber) } for _, comment := range issueComments { c, _ := comment.(map[string]interface{}) if str, ok := c["body"].(string); ok && strings.Contains(str, "*Release*") { fmt.Println(str) return } } } // unique returns a ascendingly sorted set of unique strings among its input func unique(input []string) []string { m := make(map[string]bool) for _, entry := range input { m[entry] = true } var list []string for key := range m { list = append(list, key) } sort.Strings(list) return list }