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  }