golang.org/x/build@v0.0.0-20240506185731-218518f32b70/cmd/relnote/relnote.go (about) 1 // Copyright 2017 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 // The relnote command works with release notes. 6 // It can be used to look for unfinished notes and to generate the 7 // final markdown file. 8 package main 9 10 import ( 11 "context" 12 "flag" 13 "fmt" 14 "log" 15 "os" 16 "path" 17 "path/filepath" 18 "regexp" 19 "runtime" 20 "slices" 21 "strconv" 22 "strings" 23 "time" 24 25 "golang.org/x/build/gerrit" 26 "golang.org/x/build/maintner" 27 "golang.org/x/build/repos" 28 ) 29 30 var verbose = flag.Bool("v", false, "print verbose logging") 31 32 // change is a change that was noted via a RELNOTE= comment. 33 type change struct { 34 CL *maintner.GerritCL 35 Note string // the part after RELNOTE= 36 Issue *maintner.GitHubIssue 37 } 38 39 func (c change) ID() string { 40 switch { 41 default: 42 panic("invalid change") 43 case c.CL != nil: 44 return fmt.Sprintf("CL %d", c.CL.Number) 45 case c.Issue != nil: 46 return fmt.Sprintf("https://go.dev/issue/%d", c.Issue.Number) 47 } 48 } 49 50 func (c change) URL() string { 51 switch { 52 default: 53 panic("invalid change") 54 case c.CL != nil: 55 return fmt.Sprint("https://go.dev/cl/", c.CL.Number) 56 case c.Issue != nil: 57 return fmt.Sprint("https://go.dev/issue/", c.Issue.Number) 58 } 59 } 60 61 func (c change) Subject() string { 62 switch { 63 default: 64 panic("invalid change") 65 case c.CL != nil: 66 subj := c.CL.Subject() 67 subj = strings.TrimPrefix(subj, clPackage(c.CL)+":") 68 return strings.TrimSpace(subj) 69 case c.Issue != nil: 70 return issueSubject(c.Issue) 71 } 72 } 73 74 func (c change) TextLine() string { 75 switch { 76 default: 77 panic("invalid change") 78 case c.CL != nil: 79 subj := c.CL.Subject() 80 if c.Note != "yes" && c.Note != "y" { 81 subj += "; " + c.Note 82 } 83 return subj 84 case c.Issue != nil: 85 return issueSubject(c.Issue) 86 } 87 } 88 89 func usage() { 90 out := flag.CommandLine.Output() 91 fmt.Fprintf(out, "usage:\n") 92 fmt.Fprintf(out, " relnote generate [GOROOT]\n") 93 fmt.Fprintf(out, " generate release notes from doc/next under GOROOT (default: runtime.GOROOT())\n") 94 fmt.Fprintf(out, " relnote todo PREVIOUS_RELEASE_DATE\n") 95 fmt.Fprintf(out, " report which release notes need to be written; use YYYY-MM-DD format for date of last release\n") 96 flag.PrintDefaults() 97 } 98 99 func main() { 100 log.SetPrefix("relnote: ") 101 log.SetFlags(0) 102 flag.Usage = usage 103 flag.Parse() 104 105 goroot := runtime.GOROOT() 106 if goroot == "" { 107 log.Fatalf("missing GOROOT") 108 } 109 110 // Read internal/goversion to find the next release. 111 data, err := os.ReadFile(filepath.Join(goroot, "src/internal/goversion/goversion.go")) 112 if err != nil { 113 log.Fatal(err) 114 } 115 m := regexp.MustCompile(`Version = (\d+)`).FindStringSubmatch(string(data)) 116 if m == nil { 117 log.Fatalf("cannot find Version in src/internal/goversion/goversion.go") 118 } 119 version := m[1] 120 121 // Dispatch to a subcommand if one is provided. 122 if cmd := flag.Arg(0); cmd != "" { 123 switch cmd { 124 case "generate": 125 err = generate(version, flag.Arg(1)) 126 case "todo": 127 prevDate := flag.Arg(1) 128 if prevDate == "" { 129 log.Fatal("need previous release date") 130 } 131 prevDateTime, err := time.Parse("2006-01-02", prevDate) 132 if err != nil { 133 log.Fatalf("previous release date: %s", err) 134 } 135 nextDir := filepath.Join(goroot, "doc", "next") 136 err = todo(os.Stdout, os.DirFS(nextDir), prevDateTime) 137 default: 138 err = fmt.Errorf("unknown command %q", cmd) 139 } 140 if err != nil { 141 log.Fatal(err) 142 } 143 } else { 144 usage() 145 log.Fatal("missing subcommand") 146 } 147 } 148 149 // findCLsWithRelNote finds CLs that contain a RELNOTE marker by 150 // using a Gerrit API client. Returned map is keyed by CL number. 151 func findCLsWithRelNote(client *gerrit.Client, since time.Time) (map[int]*gerrit.ChangeInfo, error) { 152 // Gerrit search operators are documented at 153 // https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators. 154 query := fmt.Sprintf(`status:merged branch:master since:%s (comment:"RELNOTE" OR comment:"RELNOTES")`, 155 since.Format("2006-01-02")) 156 cs, err := client.QueryChanges(context.Background(), query) 157 if err != nil { 158 return nil, err 159 } 160 m := make(map[int]*gerrit.ChangeInfo) // CL Number → CL. 161 for _, c := range cs { 162 m[c.ChangeNumber] = c 163 } 164 return m, nil 165 } 166 167 // packagePrefix returns the package prefix at the start of s. 168 // For example packagePrefix("net/http: add HTTP 5 support") == "net/http". 169 // If there's no package prefix, packagePrefix returns "". 170 func packagePrefix(s string) string { 171 i := strings.Index(s, ":") 172 if i < 0 { 173 return "" 174 } 175 s = s[:i] 176 if strings.Trim(s, "abcdefghijklmnopqrstuvwxyz0123456789/") != "" { 177 return "" 178 } 179 return s 180 } 181 182 // clPackage returns the package import path from the CL's commit message, 183 // or "??" if it's formatted unconventionally. 184 func clPackage(cl *maintner.GerritCL) string { 185 pkg := packagePrefix(cl.Subject()) 186 if pkg == "" { 187 return "??" 188 } 189 if r := repos.ByGerritProject[cl.Project.Project()]; r == nil { 190 return "??" 191 } else { 192 pkg = path.Join(r.ImportPath, pkg) 193 } 194 return pkg 195 } 196 197 // clRelNote extracts a RELNOTE note from a Gerrit CL commit 198 // message and any inline comments. If there isn't a RELNOTE 199 // note, it returns the empty string. 200 func clRelNote(cl *maintner.GerritCL, comments map[string][]gerrit.CommentInfo) string { 201 msg := cl.Commit.Msg 202 if strings.Contains(msg, "RELNOTE") { 203 return parseRelNote(msg) 204 } 205 // Since July 2020, Gerrit UI has replaced top-level comments 206 // with patchset-level inline comments, so don't bother looking 207 // for RELNOTE= in cl.Messages—there won't be any. Instead, do 208 // look through all inline comments that we got via Gerrit API. 209 for _, cs := range comments { 210 for _, c := range cs { 211 if strings.Contains(c.Message, "RELNOTE") { 212 return parseRelNote(c.Message) 213 } 214 } 215 } 216 return "" 217 } 218 219 // parseRelNote parses a RELNOTE annotation from the string s. 220 // It returns the empty string if no such annotation exists. 221 func parseRelNote(s string) string { 222 m := relNoteRx.FindStringSubmatch(s) 223 if m == nil { 224 return "" 225 } 226 return m[1] 227 } 228 229 var relNoteRx = regexp.MustCompile(`RELNOTES?=(.+)`) 230 231 // issuePackage returns the package import path from the issue's title, 232 // or "??" if it's formatted unconventionally. 233 func issuePackage(issue *maintner.GitHubIssue) string { 234 pkg := packagePrefix(issue.Title) 235 if pkg == "" { 236 return "??" 237 } 238 return pkg 239 } 240 241 // issueSubject returns the issue's title with the package prefix removed. 242 func issueSubject(issue *maintner.GitHubIssue) string { 243 pkg := packagePrefix(issue.Title) 244 if pkg == "" { 245 return issue.Title 246 } 247 return strings.TrimSpace(strings.TrimPrefix(issue.Title, pkg+":")) 248 } 249 250 func hasLabel(issue *maintner.GitHubIssue, label string) bool { 251 for _, l := range issue.Labels { 252 if l.Name == label { 253 return true 254 } 255 } 256 return false 257 } 258 259 var numbersRE = regexp.MustCompile(`(?m)(?:^|\s|golang/go)#([0-9]{3,})`) 260 var golangGoNumbersRE = regexp.MustCompile(`(?m)golang/go#([0-9]{3,})`) 261 262 // issueNumbers returns the golang/go issue numbers referred to by the CL. 263 func issueNumbers(cl *maintner.GerritCL) []int32 { 264 var re *regexp.Regexp 265 if cl.Project.Project() == "go" { 266 re = numbersRE 267 } else { 268 re = golangGoNumbersRE 269 } 270 271 var list []int32 272 for _, s := range re.FindAllStringSubmatch(cl.Commit.Msg, -1) { 273 if n, err := strconv.Atoi(s[1]); err == nil && n < 1e9 { 274 list = append(list, int32(n)) 275 } 276 } 277 // Remove duplicates. 278 slices.Sort(list) 279 return slices.Compact(list) 280 }