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  }