| // 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 |
| } |