golang.org/x/build@v0.0.0-20240506185731-218518f32b70/internal/task/milestones.go (about)

     1  // Copyright 2023 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  package task
     6  
     7  import (
     8  	"context"
     9  	"fmt"
    10  	"sort"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/google/go-github/github"
    15  	"github.com/shurcooL/githubv4"
    16  	wf "golang.org/x/build/internal/workflow"
    17  	goversion "golang.org/x/build/maintner/maintnerd/maintapi/version"
    18  )
    19  
    20  // MilestoneTasks contains the tasks used to check and modify GitHub issues' milestones.
    21  type MilestoneTasks struct {
    22  	Client              GitHubClientInterface
    23  	RepoOwner, RepoName string
    24  	ApproveAction       func(*wf.TaskContext) error
    25  }
    26  
    27  // ReleaseKind is the type of release being run.
    28  type ReleaseKind int
    29  
    30  const (
    31  	KindUnknown ReleaseKind = iota
    32  	KindBeta
    33  	KindRC
    34  	KindMajor
    35  	KindMinor
    36  )
    37  
    38  func (k ReleaseKind) GoString() string {
    39  	switch k {
    40  	case KindUnknown:
    41  		return "KindUnknown"
    42  	case KindBeta:
    43  		return "KindBeta"
    44  	case KindRC:
    45  		return "KindRC"
    46  	case KindMajor:
    47  		return "KindMajor"
    48  	case KindMinor:
    49  		return "KindMinor"
    50  	default:
    51  		return fmt.Sprintf("ReleaseKind(%d)", k)
    52  	}
    53  }
    54  
    55  type ReleaseMilestones struct {
    56  	// Current is the GitHub milestone number for the current Go release.
    57  	// For example, 279 for the "Go1.21" milestone (https://github.com/golang/go/milestone/279).
    58  	Current int
    59  	// Next is the GitHub milestone number for the next Go release of the same kind.
    60  	Next int
    61  }
    62  
    63  // FetchMilestones returns the milestone numbers for the version currently being
    64  // released, and the next version that outstanding issues should be moved to.
    65  // If this is a major release, it also creates its first minor release
    66  // milestone.
    67  func (m *MilestoneTasks) FetchMilestones(ctx *wf.TaskContext, currentVersion string, kind ReleaseKind) (ReleaseMilestones, error) {
    68  	x, ok := goversion.Go1PointX(currentVersion)
    69  	if !ok {
    70  		return ReleaseMilestones{}, fmt.Errorf("could not parse %q as a Go version", currentVersion)
    71  	}
    72  	majorVersion := fmt.Sprintf("go1.%d", x)
    73  
    74  	// Betas, RCs, and major releases use the major version's milestone.
    75  	if kind == KindBeta || kind == KindRC || kind == KindMajor {
    76  		currentVersion = majorVersion
    77  	}
    78  
    79  	currentMilestone, err := m.Client.FetchMilestone(ctx, m.RepoOwner, m.RepoName, uppercaseVersion(currentVersion), false)
    80  	if err != nil {
    81  		return ReleaseMilestones{}, err
    82  	}
    83  	nextV, err := nextVersion(currentVersion)
    84  	if err != nil {
    85  		return ReleaseMilestones{}, err
    86  	}
    87  	nextMilestone, err := m.Client.FetchMilestone(ctx, m.RepoOwner, m.RepoName, uppercaseVersion(nextV), true)
    88  	if err != nil {
    89  		return ReleaseMilestones{}, err
    90  	}
    91  	if kind == KindMajor {
    92  		// Create the first minor release milestone too.
    93  		firstMinor := majorVersion + ".1"
    94  		if err != nil {
    95  			return ReleaseMilestones{}, err
    96  		}
    97  		_, err = m.Client.FetchMilestone(ctx, m.RepoOwner, m.RepoName, uppercaseVersion(firstMinor), true)
    98  		if err != nil {
    99  			return ReleaseMilestones{}, err
   100  		}
   101  	}
   102  	return ReleaseMilestones{Current: currentMilestone, Next: nextMilestone}, nil
   103  }
   104  
   105  func uppercaseVersion(version string) string {
   106  	return strings.Replace(version, "go", "Go", 1)
   107  }
   108  
   109  // CheckBlockers returns an error if there are open release blockers in
   110  // the current milestone.
   111  func (m *MilestoneTasks) CheckBlockers(ctx *wf.TaskContext, milestones ReleaseMilestones, version string, kind ReleaseKind) error {
   112  	issues, err := m.Client.FetchMilestoneIssues(ctx, m.RepoOwner, m.RepoName, milestones.Current)
   113  	if err != nil {
   114  		return err
   115  	}
   116  	var blockers []string
   117  	for number, labels := range issues {
   118  		releaseBlocker := labels["release-blocker"]
   119  		switch {
   120  		case kind == KindBeta && strings.HasSuffix(version, "beta1") && labels["okay-after-beta1"],
   121  			kind == KindRC && strings.HasSuffix(version, "rc1") && labels["okay-after-rc1"]:
   122  			releaseBlocker = false
   123  		}
   124  		if releaseBlocker {
   125  			blockers = append(blockers, fmt.Sprintf("https://go.dev/issue/%v", number))
   126  		}
   127  	}
   128  	sort.Strings(blockers)
   129  	if len(blockers) == 0 {
   130  		return nil
   131  	}
   132  	ctx.Printf("There are open release blockers in https://github.com/golang/go/milestone/%d. Check that they're expected and approve this task:\n%v",
   133  		milestones.Current, strings.Join(blockers, "\n"))
   134  	return m.ApproveAction(ctx)
   135  }
   136  
   137  // PushIssues updates issues to reflect a finished release.
   138  // For major and minor releases, it moves issues to the next milestone and closes the current milestone.
   139  // For pre-releases, it cleans up any "okay-after-..." labels in the current milestone that are done serving their purpose.
   140  func (m *MilestoneTasks) PushIssues(ctx *wf.TaskContext, milestones ReleaseMilestones, version string, kind ReleaseKind) error {
   141  	issues, err := m.Client.FetchMilestoneIssues(ctx, m.RepoOwner, m.RepoName, milestones.Current)
   142  	if err != nil {
   143  		return err
   144  	}
   145  	ctx.Printf("Processing %d open issues in milestone %d.", len(issues), milestones.Current)
   146  	for issueNumber, labels := range issues {
   147  		var newLabels *[]string
   148  		var newMilestone *int
   149  		var actions []string // A short description of actions taken, for the log line.
   150  		removeLabel := func(name string) {
   151  			if !labels[name] {
   152  				return
   153  			}
   154  			newLabels = new([]string)
   155  			for label := range labels {
   156  				if label == name {
   157  					continue
   158  				}
   159  				*newLabels = append(*newLabels, label)
   160  			}
   161  			actions = append(actions, fmt.Sprintf("removed label %q", name))
   162  		}
   163  		if kind == KindBeta && strings.HasSuffix(version, "beta1") {
   164  			removeLabel("okay-after-beta1")
   165  		} else if kind == KindRC && strings.HasSuffix(version, "rc1") {
   166  			removeLabel("okay-after-rc1")
   167  		} else if kind == KindMajor || kind == KindMinor {
   168  			newMilestone = &milestones.Next
   169  			actions = append(actions, fmt.Sprintf("pushed to milestone %d", milestones.Next))
   170  		}
   171  		if newMilestone == nil && newLabels == nil {
   172  			ctx.Printf("Nothing to do for issue %d.", issueNumber)
   173  			continue
   174  		}
   175  		_, _, err := m.Client.EditIssue(ctx, m.RepoOwner, m.RepoName, issueNumber, &github.IssueRequest{
   176  			Milestone: newMilestone,
   177  			Labels:    newLabels,
   178  		})
   179  		if err != nil {
   180  			return err
   181  		}
   182  		ctx.Printf("Updated issue %d: %s.", issueNumber, strings.Join(actions, ", "))
   183  	}
   184  	if kind == KindMajor || kind == KindMinor {
   185  		_, _, err := m.Client.EditMilestone(ctx, m.RepoOwner, m.RepoName, milestones.Current, &github.Milestone{
   186  			State: github.String("closed"),
   187  		})
   188  		if err != nil {
   189  			return err
   190  		}
   191  		ctx.Printf("Closed milestone %d.", milestones.Current)
   192  	}
   193  	return nil
   194  }
   195  
   196  // PingEarlyIssues pings early-in-cycle issues in the development major release milestone.
   197  // This is done once at the opening of a release cycle, currently via a standalone workflow.
   198  //
   199  // develVersion is a value like 22 representing that Go 1.22 is the major version whose
   200  // development has recently started, and whose early-in-cycle issues are to be pinged.
   201  func (m *MilestoneTasks) PingEarlyIssues(ctx *wf.TaskContext, develVersion int, openTreeURL string) (result struct{}, _ error) {
   202  	milestoneName := fmt.Sprintf("Go1.%d", develVersion)
   203  
   204  	gh, ok := m.Client.(*GitHubClient)
   205  	if !ok || gh.V4 == nil {
   206  		// TODO(go.dev/issue/58856): Decide if it's worth moving the GraphQL query/mutation
   207  		// into GitHubClientInterface. That kinda harms readability because GraphQL code is
   208  		// basically a flexible API call, so it's most readable when close to where they're
   209  		// used. This also depends on what kind of tests we'll want to use for this.
   210  		return struct{}{}, fmt.Errorf("no GitHub API v4 client")
   211  	}
   212  
   213  	// Find all open early-in-cycle issues in the development major release milestone.
   214  	type issue struct {
   215  		ID     githubv4.ID
   216  		Number int
   217  		Title  string
   218  
   219  		TimelineItems struct {
   220  			Nodes []struct {
   221  				IssueComment struct {
   222  					Author struct{ Login string }
   223  					Body   string
   224  				} `graphql:"...on IssueComment"`
   225  			}
   226  		} `graphql:"timelineItems(since: $avoidDupSince, itemTypes: ISSUE_COMMENT, last: 100)"`
   227  	}
   228  	var earlyIssues []issue
   229  	milestoneNumber, err := m.Client.FetchMilestone(ctx, m.RepoOwner, m.RepoName, milestoneName, false)
   230  	if err != nil {
   231  		return struct{}{}, err
   232  	}
   233  	variables := map[string]interface{}{
   234  		"repoOwner":       githubv4.String(m.RepoOwner),
   235  		"repoName":        githubv4.String(m.RepoName),
   236  		"avoidDupSince":   githubv4.DateTime{Time: time.Now().Add(-30 * 24 * time.Hour)},
   237  		"milestoneNumber": githubv4.String(fmt.Sprint(milestoneNumber)), // For some reason GitHub API v4 uses string type for milestone numbers.
   238  		"issueCursor":     (*githubv4.String)(nil),
   239  	}
   240  	for {
   241  		var q struct {
   242  			Repository struct {
   243  				Issues struct {
   244  					Nodes    []issue
   245  					PageInfo struct {
   246  						EndCursor   githubv4.String
   247  						HasNextPage bool
   248  					}
   249  				} `graphql:"issues(first: 100, after: $issueCursor, filterBy: {states: OPEN, labels: \"early-in-cycle\", milestoneNumber: $milestoneNumber}, orderBy: {field: CREATED_AT, direction: ASC})"`
   250  			} `graphql:"repository(owner: $repoOwner, name: $repoName)"`
   251  		}
   252  		err := gh.V4.Query(ctx, &q, variables)
   253  		if err != nil {
   254  			return struct{}{}, err
   255  		}
   256  		earlyIssues = append(earlyIssues, q.Repository.Issues.Nodes...)
   257  		if !q.Repository.Issues.PageInfo.HasNextPage {
   258  			break
   259  		}
   260  		variables["issueCursor"] = githubv4.NewString(q.Repository.Issues.PageInfo.EndCursor)
   261  	}
   262  
   263  	// Ping them.
   264  	ctx.Printf("Processing %d early-in-cycle issues in %s milestone (milestone number %d).", len(earlyIssues), milestoneName, milestoneNumber)
   265  EarlyIssuesLoop:
   266  	for _, i := range earlyIssues {
   267  		for _, n := range i.TimelineItems.Nodes {
   268  			if n.IssueComment.Author.Login == "gopherbot" && strings.Contains(n.IssueComment.Body, "friendly reminder") {
   269  				ctx.Printf("Skipping issue %d, it was already pinged.", i.Number)
   270  				continue EarlyIssuesLoop
   271  			}
   272  		}
   273  
   274  		// Post a comment.
   275  		const dryRun = false
   276  		if dryRun {
   277  			ctx.Printf("[dry run] Would've pinged issue %d (%.32s…).", i.Number, i.Title)
   278  			continue
   279  		}
   280  		err := m.Client.PostComment(ctx, i.ID, fmt.Sprintf("This issue is currently labeled as early-in-cycle for Go 1.%d.\n"+
   281  			"That [time is now](%s), so a friendly reminder to look at it again.", develVersion, openTreeURL))
   282  		if err != nil {
   283  			return struct{}{}, err
   284  		}
   285  		ctx.Printf("Pinged issue %d (%.32s…).", i.Number, i.Title)
   286  		time.Sleep(3 * time.Second) // Take a moment between pinging issues to avoid a high rate of addComment mutations.
   287  	}
   288  
   289  	return struct{}{}, nil
   290  }
   291  
   292  // GitHubClientInterface is a wrapper around the GitHub v3 and v4 APIs, for
   293  // testing and dry-run support.
   294  type GitHubClientInterface interface {
   295  	// FetchMilestone returns the number of the GitHub milestone with the specified name.
   296  	// If create is true, and the milestone doesn't exist, it will be created.
   297  	FetchMilestone(ctx context.Context, owner, repo, name string, create bool) (int, error)
   298  
   299  	// FetchMilestoneIssues returns all the open issues in the specified milestone
   300  	// and their labels.
   301  	FetchMilestoneIssues(ctx context.Context, owner, repo string, milestoneID int) (map[int]map[string]bool, error)
   302  
   303  	// See github.Client.Issues.Edit.
   304  	EditIssue(ctx context.Context, owner string, repo string, number int, issue *github.IssueRequest) (*github.Issue, *github.Response, error)
   305  
   306  	// See github.Client.Issues.EditMilestone.
   307  	EditMilestone(ctx context.Context, owner string, repo string, number int, milestone *github.Milestone) (*github.Milestone, *github.Response, error)
   308  
   309  	// PostComment creates a comment on a GitHub issue or pull request
   310  	// identified by the given GitHub Node ID.
   311  	PostComment(_ context.Context, id githubv4.ID, body string) error
   312  }
   313  
   314  type GitHubClient struct {
   315  	V3 *github.Client
   316  	V4 *githubv4.Client
   317  }
   318  
   319  func (c *GitHubClient) FetchMilestone(ctx context.Context, owner, repo, name string, create bool) (int, error) {
   320  	n, found, err := findMilestone(ctx, c.V4, owner, repo, name)
   321  	if err != nil {
   322  		return 0, err
   323  	}
   324  	if found {
   325  		return n, nil
   326  	} else if !create {
   327  		return 0, fmt.Errorf("no milestone named %q found, and creation was disabled", name)
   328  	}
   329  	m, _, createErr := c.V3.Issues.CreateMilestone(ctx, owner, repo, &github.Milestone{
   330  		Title: github.String(name),
   331  	})
   332  	if createErr != nil {
   333  		return 0, fmt.Errorf("could not find an open milestone named %q and creating it failed: %v", name, createErr)
   334  	}
   335  	return *m.Number, nil
   336  }
   337  
   338  func findMilestone(ctx context.Context, client *githubv4.Client, owner, repo, name string) (int, bool, error) {
   339  	var query struct {
   340  		Repository struct {
   341  			Milestones struct {
   342  				Nodes []struct {
   343  					Title  string
   344  					Number int
   345  					State  string
   346  				}
   347  			} `graphql:"milestones(first:10, query: $milestoneName)"`
   348  		} `graphql:"repository(owner: $repoOwner, name: $repoName)"`
   349  	}
   350  	if err := client.Query(ctx, &query, map[string]interface{}{
   351  		"repoOwner":     githubv4.String(owner),
   352  		"repoName":      githubv4.String(repo),
   353  		"milestoneName": githubv4.String(name),
   354  	}); err != nil {
   355  		return 0, false, err
   356  	}
   357  	// The milestone query is case-insensitive and a partial match; we're okay
   358  	// with case variations but it needs to be a full match.
   359  	var open, closed []string
   360  	milestoneNumber := 0
   361  	for _, m := range query.Repository.Milestones.Nodes {
   362  		if strings.ToLower(name) != strings.ToLower(m.Title) {
   363  			continue
   364  		}
   365  		if m.State == "OPEN" {
   366  			open = append(open, m.Title)
   367  			milestoneNumber = m.Number
   368  		} else {
   369  			closed = append(closed, m.Title)
   370  		}
   371  	}
   372  	// GitHub allows "go" and "Go" to exist at the same time.
   373  	// If there's any confusion, fail: we expect either one open milestone,
   374  	// or no matching milestones at all.
   375  	switch {
   376  	case len(open) == 1:
   377  		return milestoneNumber, true, nil
   378  	case len(open) > 1:
   379  		return 0, false, fmt.Errorf("multiple open milestones matching %q: %q", name, open)
   380  	// No open milestones.
   381  	case len(closed) == 0:
   382  		return 0, false, nil
   383  	case len(closed) > 0:
   384  		return 0, false, fmt.Errorf("no open milestones matching %q, but some closed: %q (re-open or delete?)", name, closed)
   385  	}
   386  	// The switch above is exhaustive.
   387  	panic(fmt.Errorf("unhandled case: open: %q closed: %q", open, closed))
   388  }
   389  
   390  func (c *GitHubClient) FetchMilestoneIssues(ctx context.Context, owner, repo string, milestoneID int) (map[int]map[string]bool, error) {
   391  	issues := map[int]map[string]bool{}
   392  	var query struct {
   393  		Repository struct {
   394  			Issues struct {
   395  				PageInfo struct {
   396  					EndCursor   githubv4.String
   397  					HasNextPage bool
   398  				}
   399  
   400  				Nodes []struct {
   401  					Number int
   402  					ID     githubv4.ID
   403  					Title  string
   404  					Labels struct {
   405  						PageInfo struct {
   406  							HasNextPage bool
   407  						}
   408  						Nodes []struct {
   409  							Name string
   410  						}
   411  					} `graphql:"labels(first:10)"`
   412  				}
   413  			} `graphql:"issues(first:100, after:$afterToken, filterBy:{states:OPEN, milestoneNumber:$milestoneNumber})"`
   414  		} `graphql:"repository(owner: $repoOwner, name: $repoName)"`
   415  	}
   416  	var afterToken *githubv4.String
   417  more:
   418  	if err := c.V4.Query(ctx, &query, map[string]interface{}{
   419  		"repoOwner":       githubv4.String(owner),
   420  		"repoName":        githubv4.String(repo),
   421  		"milestoneNumber": githubv4.String(fmt.Sprint(milestoneID)),
   422  		"afterToken":      afterToken,
   423  	}); err != nil {
   424  		return nil, err
   425  	}
   426  	for _, issue := range query.Repository.Issues.Nodes {
   427  		if issue.Labels.PageInfo.HasNextPage {
   428  			return nil, fmt.Errorf("issue %v (#%v) has more than 10 labels", issue.Title, issue.Number)
   429  		}
   430  		labels := map[string]bool{}
   431  		for _, label := range issue.Labels.Nodes {
   432  			labels[label.Name] = true
   433  		}
   434  		issues[issue.Number] = labels
   435  	}
   436  	if query.Repository.Issues.PageInfo.HasNextPage {
   437  		afterToken = &query.Repository.Issues.PageInfo.EndCursor
   438  		goto more
   439  	}
   440  	return issues, nil
   441  }
   442  
   443  func (c *GitHubClient) EditIssue(ctx context.Context, owner string, repo string, number int, issue *github.IssueRequest) (*github.Issue, *github.Response, error) {
   444  	return c.V3.Issues.Edit(ctx, owner, repo, number, issue)
   445  }
   446  
   447  func (c *GitHubClient) EditMilestone(ctx context.Context, owner string, repo string, number int, milestone *github.Milestone) (*github.Milestone, *github.Response, error) {
   448  	return c.V3.Issues.EditMilestone(ctx, owner, repo, number, milestone)
   449  }
   450  
   451  func (c *GitHubClient) PostComment(ctx context.Context, id githubv4.ID, body string) error {
   452  	return c.V4.Mutate(ctx, new(struct {
   453  		AddComment struct {
   454  			ClientMutationID string // Unused; GraphQL doesn't allow for mutations to return nothing.
   455  		} `graphql:"addComment(input: $input)"`
   456  	}), githubv4.AddCommentInput{
   457  		SubjectID: id,
   458  		Body:      githubv4.String(body),
   459  	}, nil)
   460  }