vitess.io/vitess@v0.16.2/go/tools/release-notes/release_notes.go (about) 1 /* 2 Copyright 2021 The Vitess Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package main 18 19 import ( 20 "bytes" 21 "encoding/json" 22 "fmt" 23 "log" 24 "os" 25 "os/exec" 26 "path" 27 "regexp" 28 "sort" 29 "strings" 30 "sync" 31 "text/template" 32 33 "github.com/spf13/pflag" 34 ) 35 36 type ( 37 label struct { 38 Name string `json:"name"` 39 } 40 41 labels []label 42 43 author struct { 44 Login string `json:"login"` 45 } 46 47 prInfo struct { 48 Labels labels `json:"labels"` 49 Number int `json:"number"` 50 Title string `json:"title"` 51 Author author `json:"author"` 52 } 53 54 prsByComponent = map[string][]prInfo 55 56 prsByType = map[string]prsByComponent 57 58 sortedPRComponent struct { 59 Name string 60 PrInfos []prInfo 61 } 62 63 sortedPRType struct { 64 Name string 65 Components []sortedPRComponent 66 } 67 68 knownIssue struct { 69 Number int `json:"number"` 70 Title string `json:"title"` 71 } 72 73 releaseNote struct { 74 Version, VersionUnderscore string 75 Announcement string 76 KnownIssues string 77 AddDetails string 78 PathToChangeLogFileOnGH, ChangeLog, ChangeMetrics string 79 SubDirPath string 80 } 81 ) 82 83 var ( 84 releaseNotesPath = `changelog/` 85 ) 86 87 const ( 88 releaseNotesPathGitHub = `https://github.com/vitessio/vitess/blob/main/` 89 markdownTemplate = `# Release of Vitess {{.Version}} 90 91 {{- if or .Announcement .AddDetails }} 92 {{ .Announcement }} 93 {{- end }} 94 95 {{- if and (or .Announcement .AddDetails) (or .KnownIssues .ChangeLog) }} 96 ------------ 97 {{- end }} 98 99 {{- if .KnownIssues }} 100 ## Known Issues 101 {{ .KnownIssues }} 102 {{- end }} 103 104 {{- if .ChangeLog }} 105 The entire changelog for this release can be found [here]({{ .PathToChangeLogFileOnGH }}). 106 {{- end }} 107 108 {{- if .ChangeLog }} 109 {{ .ChangeMetrics }} 110 {{- end }} 111 ` 112 113 markdownTemplateChangelog = `# Changelog of Vitess {{.Version}} 114 {{ .ChangeLog }} 115 ` 116 117 markdownTemplatePR = ` 118 {{- range $type := . }} 119 ### {{ $type.Name }} 120 {{- range $component := $type.Components }} 121 #### {{ $component.Name }} 122 {{- range $prInfo := $component.PrInfos }} 123 * {{ $prInfo.Title }} [#{{ $prInfo.Number }}](https://github.com/vitessio/vitess/pull/{{ $prInfo.Number }}) 124 {{- end }} 125 {{- end }} 126 {{- end }} 127 ` 128 129 markdownTemplateKnownIssues = ` 130 {{- range $issue := . }} 131 * {{ $issue.Title }} #{{ $issue.Number }} 132 {{- end }} 133 ` 134 135 prefixType = "Type: " 136 prefixComponent = "Component: " 137 numberOfThreads = 10 138 lengthOfSingleSHA = 40 139 ) 140 141 func (rn *releaseNote) generate(rnFile, changelogFile *os.File) error { 142 var err error 143 // Generate the release notes 144 rn.PathToChangeLogFileOnGH = releaseNotesPathGitHub + path.Join(rn.SubDirPath, "changelog.md") 145 if rnFile == nil { 146 rnFile, err = os.OpenFile(path.Join(rn.SubDirPath, "release_notes.md"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666) 147 if err != nil { 148 return err 149 } 150 } 151 152 t := template.Must(template.New("release_notes").Parse(markdownTemplate)) 153 err = t.ExecuteTemplate(rnFile, "release_notes", rn) 154 if err != nil { 155 return err 156 } 157 158 // Generate the changelog 159 if changelogFile == nil { 160 changelogFile, err = os.OpenFile(path.Join(rn.SubDirPath, "changelog.md"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666) 161 if err != nil { 162 return err 163 } 164 } 165 t = template.Must(template.New("release_notes_changelog").Parse(markdownTemplateChangelog)) 166 err = t.ExecuteTemplate(changelogFile, "release_notes_changelog", rn) 167 if err != nil { 168 return err 169 } 170 return nil 171 } 172 173 func loadKnownIssues(release string) ([]knownIssue, error) { 174 idx := strings.Index(release, ".") 175 if idx > -1 { 176 release = release[:idx] 177 } 178 label := fmt.Sprintf("Known issue: %s", release) 179 out, err := execCmd("gh", "issue", "list", "--repo", "vitessio/vitess", "--label", label, "--json", "title,number") 180 if err != nil { 181 return nil, err 182 } 183 var knownIssues []knownIssue 184 err = json.Unmarshal(out, &knownIssues) 185 if err != nil { 186 return nil, err 187 } 188 return knownIssues, nil 189 } 190 191 func loadMergedPRs(from, to string) (prs []string, authors []string, commitCount int, err error) { 192 // load the git log with "author \t title \t parents" 193 out, err := execCmd("git", "log", `--pretty=format:%ae%x09%s%x09%P%x09%h`, fmt.Sprintf("%s..%s", from, to)) 194 195 if err != nil { 196 return 197 } 198 199 return parseGitLog(string(out)) 200 } 201 202 func parseGitLog(s string) (prs []string, authorCommits []string, commitCount int, err error) { 203 rx := regexp.MustCompile(`(.+)\t(.+)\t(.+)\t(.+)`) 204 mergePR := regexp.MustCompile(`Merge pull request #(\d+)`) 205 squashPR := regexp.MustCompile(`\(#(\d+)\)`) 206 authMap := map[string]string{} // here we will store email <-> gh user mappings 207 lines := strings.Split(s, "\n") 208 for _, line := range lines { 209 lineInfo := rx.FindStringSubmatch(line) 210 if len(lineInfo) != 5 { 211 log.Fatalf("failed to parse the output from git log: %s", line) 212 } 213 authorEmail := lineInfo[1] 214 title := lineInfo[2] 215 parents := lineInfo[3] 216 sha := lineInfo[4] 217 merged := mergePR.FindStringSubmatch(title) 218 if len(merged) == 2 { 219 // this is a merged PR. remember the PR # 220 prs = append(prs, merged[1]) 221 continue 222 } 223 224 if len(parents) <= lengthOfSingleSHA { 225 // we have a single parent, and the commit counts 226 commitCount++ 227 if _, exists := authMap[authorEmail]; !exists { 228 authMap[authorEmail] = sha 229 } 230 } 231 232 squashed := squashPR.FindStringSubmatch(title) 233 if len(squashed) == 2 { 234 // this is a merged PR. remember the PR # 235 prs = append(prs, squashed[1]) 236 continue 237 } 238 } 239 240 for _, author := range authMap { 241 authorCommits = append(authorCommits, author) 242 } 243 244 sort.Strings(prs) 245 sort.Strings(authorCommits) // not really needed, but makes testing easier 246 247 return 248 } 249 250 func execCmd(name string, arg ...string) ([]byte, error) { 251 out, err := exec.Command(name, arg...).Output() 252 if err != nil { 253 execErr, ok := err.(*exec.ExitError) 254 if ok { 255 return nil, fmt.Errorf("%s:\nstderr: %s\nstdout: %s", err.Error(), execErr.Stderr, out) 256 } 257 if strings.Contains(err.Error(), " executable file not found in") { 258 return nil, fmt.Errorf("the command `gh` seems to be missing. Please install it from https://github.com/cli/cli") 259 } 260 return nil, err 261 } 262 return out, nil 263 } 264 265 func loadPRInfo(pr string) (prInfo, error) { 266 out, err := execCmd("gh", "pr", "view", pr, "--json", "title,number,labels,author") 267 if err != nil { 268 return prInfo{}, err 269 } 270 var prInfo prInfo 271 err = json.Unmarshal(out, &prInfo) 272 return prInfo, err 273 } 274 275 func loadAuthorInfo(sha string) (string, error) { 276 out, err := execCmd("gh", "api", "/repos/vitessio/vitess/commits/"+sha) 277 if err != nil { 278 return "", err 279 } 280 var prInfo prInfo 281 err = json.Unmarshal(out, &prInfo) 282 if err != nil { 283 return "", err 284 } 285 return prInfo.Author.Login, nil 286 } 287 288 type req struct { 289 isPR bool 290 key string 291 } 292 293 func loadAllPRs(prs, authorCommits []string) ([]prInfo, []string, error) { 294 errChan := make(chan error) 295 wgDone := make(chan bool) 296 prChan := make(chan req, len(prs)+len(authorCommits)) 297 // fill the work queue 298 for _, s := range prs { 299 prChan <- req{isPR: true, key: s} 300 } 301 for _, s := range authorCommits { 302 prChan <- req{isPR: false, key: s} 303 } 304 close(prChan) 305 306 var prInfos []prInfo 307 var authors []string 308 fmt.Printf("Found %d merged PRs. Loading PR info", len(prs)) 309 wg := sync.WaitGroup{} 310 mu := sync.Mutex{} 311 312 shouldLoad := func(in string) bool { 313 if in == "" { 314 return false 315 } 316 mu.Lock() 317 defer mu.Unlock() 318 319 for _, existing := range authors { 320 if existing == in { 321 return false 322 } 323 } 324 return true 325 } 326 addAuthor := func(in string) { 327 mu.Lock() 328 defer mu.Unlock() 329 authors = append(authors, in) 330 } 331 addPR := func(in prInfo) { 332 mu.Lock() 333 defer mu.Unlock() 334 prInfos = append(prInfos, in) 335 } 336 337 for i := 0; i < numberOfThreads; i++ { 338 wg.Add(1) 339 go func() { 340 // load meta data about PRs 341 defer wg.Done() 342 343 for b := range prChan { 344 fmt.Print(".") 345 if b.isPR { 346 prInfo, err := loadPRInfo(b.key) 347 if err != nil { 348 errChan <- err 349 break 350 } 351 addPR(prInfo) 352 continue 353 } 354 author, err := loadAuthorInfo(b.key) 355 if err != nil { 356 errChan <- err 357 break 358 } 359 if shouldLoad(author) { 360 addAuthor(author) 361 } 362 363 } 364 }() 365 } 366 367 go func() { 368 // wait for the loading to finish 369 wg.Wait() 370 close(wgDone) 371 }() 372 373 var err error 374 select { 375 case <-wgDone: 376 break 377 case err = <-errChan: 378 break 379 } 380 381 fmt.Println() 382 383 sort.Strings(authors) 384 385 return prInfos, authors, err 386 } 387 388 func groupPRs(prInfos []prInfo) prsByType { 389 prPerType := prsByType{} 390 391 for _, info := range prInfos { 392 var typ, component string 393 for _, lbl := range info.Labels { 394 switch { 395 case strings.HasPrefix(lbl.Name, prefixType): 396 typ = strings.TrimPrefix(lbl.Name, prefixType) 397 case strings.HasPrefix(lbl.Name, prefixComponent): 398 component = strings.TrimPrefix(lbl.Name, prefixComponent) 399 } 400 } 401 switch typ { 402 case "": 403 typ = "Other" 404 case "Bug": 405 typ = "Bug fixes" 406 } 407 408 if component == "" { 409 component = "Other" 410 } 411 components, exists := prPerType[typ] 412 if !exists { 413 components = prsByComponent{} 414 prPerType[typ] = components 415 } 416 417 prsPerComponentAndType := components[component] 418 components[component] = append(prsPerComponentAndType, info) 419 } 420 return prPerType 421 } 422 423 func createSortedPrTypeSlice(prPerType prsByType) []sortedPRType { 424 var data []sortedPRType 425 for typeKey, typeElem := range prPerType { 426 newPrType := sortedPRType{ 427 Name: typeKey, 428 } 429 for componentKey, prInfos := range typeElem { 430 newComponent := sortedPRComponent{ 431 Name: componentKey, 432 PrInfos: prInfos, 433 } 434 sort.Slice(newComponent.PrInfos, func(i, j int) bool { 435 return newComponent.PrInfos[i].Number < newComponent.PrInfos[j].Number 436 }) 437 newPrType.Components = append(newPrType.Components, newComponent) 438 } 439 sort.Slice(newPrType.Components, func(i, j int) bool { 440 return newPrType.Components[i].Name < newPrType.Components[j].Name 441 }) 442 data = append(data, newPrType) 443 } 444 sort.Slice(data, func(i, j int) bool { 445 return data[i].Name < data[j].Name 446 }) 447 return data 448 } 449 450 func releaseSummary(summaryFile string) (string, error) { 451 contentSummary, err := os.ReadFile(summaryFile) 452 if err != nil { 453 return "", err 454 } 455 return string(contentSummary), nil 456 } 457 458 func getStringForPullRequestInfos(prPerType prsByType) (string, error) { 459 data := createSortedPrTypeSlice(prPerType) 460 461 t := template.Must(template.New("markdownTemplatePR").Parse(markdownTemplatePR)) 462 buff := bytes.Buffer{} 463 if err := t.ExecuteTemplate(&buff, "markdownTemplatePR", data); err != nil { 464 return "", err 465 } 466 return buff.String(), nil 467 } 468 469 func getStringForKnownIssues(issues []knownIssue) (string, error) { 470 if len(issues) == 0 { 471 return "", nil 472 } 473 t := template.Must(template.New("markdownTemplateKnownIssues").Parse(markdownTemplateKnownIssues)) 474 buff := bytes.Buffer{} 475 if err := t.ExecuteTemplate(&buff, "markdownTemplateKnownIssues", issues); err != nil { 476 return "", err 477 } 478 return buff.String(), nil 479 } 480 481 func groupAndStringifyPullRequest(pr []prInfo) (string, error) { 482 if len(pr) == 0 { 483 return "", nil 484 } 485 prPerType := groupPRs(pr) 486 prStr, err := getStringForPullRequestInfos(prPerType) 487 if err != nil { 488 return "", err 489 } 490 return prStr, nil 491 } 492 493 func main() { 494 var ( 495 from, versionName, summaryFile string 496 to = "HEAD" 497 ) 498 pflag.StringVarP(&from, "from", "f", "", "from sha/tag/branch") 499 pflag.StringVarP(&to, "to", "t", to, "to sha/tag/branch") 500 pflag.StringVarP(&versionName, "version", "v", "", "name of the version (has to be the following format: v11.0.0)") 501 pflag.StringVarP(&summaryFile, "summary", "s", "", "readme file on which there is a summary of the release") 502 pflag.Parse() 503 504 // The -version flag must be of a valid format. 505 rx := regexp.MustCompile(`v([0-9]+)\.([0-9]+)\.([0-9]+)`) 506 // There should be 4 sub-matches, input: "v14.0.0", output: ["v14.0.0", "14", "0", "0"]. 507 versionMatch := rx.FindStringSubmatch(versionName) 508 if len(versionMatch) != 4 { 509 log.Fatal("The --version flag must be set using a valid format. Format: 'vX.X.X'.") 510 } 511 512 // Define the path to the release notes folder 513 majorVersion := versionMatch[1] + "." + versionMatch[2] 514 patchVersion := versionMatch[1] + "." + versionMatch[2] + "." + versionMatch[3] 515 releaseNotesPath = path.Join(releaseNotesPath, majorVersion, patchVersion) 516 517 err := os.MkdirAll(releaseNotesPath, os.ModePerm) 518 if err != nil { 519 log.Fatal(err) 520 } 521 522 releaseNotes := releaseNote{ 523 Version: versionName, 524 VersionUnderscore: fmt.Sprintf("%s_%s_%s", versionMatch[1], versionMatch[2], versionMatch[3]), // v14.0.0 -> 14_0_0, this is used to format filenames. 525 SubDirPath: releaseNotesPath, 526 } 527 528 // summary of the release 529 if summaryFile != "" { 530 summary, err := releaseSummary(summaryFile) 531 if err != nil { 532 log.Fatal(err) 533 } 534 releaseNotes.Announcement = summary 535 } 536 537 // known issues 538 knownIssues, err := loadKnownIssues(versionName) 539 if err != nil { 540 log.Fatal(err) 541 } 542 knownIssuesStr, err := getStringForKnownIssues(knownIssues) 543 if err != nil { 544 log.Fatal(err) 545 } 546 releaseNotes.KnownIssues = knownIssuesStr 547 548 // changelog with pull requests 549 prs, authorCommits, commits, err := loadMergedPRs(from, to) 550 if err != nil { 551 log.Fatal(err) 552 } 553 prInfos, authors, err := loadAllPRs(prs, authorCommits) 554 if err != nil { 555 log.Fatal(err) 556 } 557 releaseNotes.ChangeLog, err = groupAndStringifyPullRequest(prInfos) 558 if err != nil { 559 log.Fatal(err) 560 } 561 562 // changelog metrics 563 if commits > 0 && len(authors) > 0 { 564 releaseNotes.ChangeMetrics = fmt.Sprintf(` 565 The release includes %d commits (excluding merges) 566 567 Thanks to all our contributors: @%s 568 `, commits, strings.Join(authors, ", @")) 569 } 570 571 if err := releaseNotes.generate(nil, nil); err != nil { 572 log.Fatal(err) 573 } 574 }