| // Copyright 2023 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package task |
| |
| import ( |
| "context" |
| "errors" |
| "fmt" |
| "io/fs" |
| "net/url" |
| "sort" |
| "strings" |
| "time" |
| |
| "github.com/google/go-github/v48/github" |
| "github.com/shurcooL/githubv4" |
| wf "golang.org/x/build/internal/workflow" |
| goversion "golang.org/x/build/maintner/maintnerd/maintapi/version" |
| ) |
| |
| // MilestoneTasks contains the tasks used to check and modify GitHub issues' milestones. |
| type MilestoneTasks struct { |
| Client GitHubClientInterface |
| RepoOwner, RepoName string |
| ApproveAction func(*wf.TaskContext) error |
| } |
| |
| // ReleaseKind is the type of release being run. |
| type ReleaseKind int |
| |
| const ( |
| KindUnknown ReleaseKind = iota |
| KindBeta |
| KindRC |
| KindMajor |
| KindMinor |
| ) |
| |
| func (k ReleaseKind) GoString() string { |
| switch k { |
| case KindUnknown: |
| return "KindUnknown" |
| case KindBeta: |
| return "KindBeta" |
| case KindRC: |
| return "KindRC" |
| case KindMajor: |
| return "KindMajor" |
| case KindMinor: |
| return "KindMinor" |
| default: |
| return fmt.Sprintf("ReleaseKind(%d)", k) |
| } |
| } |
| |
| type ReleaseMilestones struct { |
| // Current is the GitHub milestone number for the current Go release. |
| // For example, 279 for the "Go1.21" milestone (https://github.com/golang/go/milestone/279). |
| Current int |
| // Next is the GitHub milestone number for the next Go release of the same kind. |
| Next int |
| } |
| |
| // FetchMilestones returns the milestone numbers for the version currently being |
| // released, and the next version that outstanding issues should be moved to. |
| // If this is a major release, it also creates its first minor release |
| // milestone. |
| func (m *MilestoneTasks) FetchMilestones(ctx *wf.TaskContext, currentVersion string, kind ReleaseKind) (ReleaseMilestones, error) { |
| x, ok := goversion.Go1PointX(currentVersion) |
| if !ok { |
| return ReleaseMilestones{}, fmt.Errorf("could not parse %q as a Go version", currentVersion) |
| } |
| majorVersion := fmt.Sprintf("go1.%d", x) |
| |
| // Betas, RCs, and major releases use the major version's milestone. |
| if kind == KindBeta || kind == KindRC || kind == KindMajor { |
| currentVersion = majorVersion |
| } |
| |
| currentMilestone, err := m.Client.FetchMilestone(ctx, m.RepoOwner, m.RepoName, uppercaseVersion(currentVersion), false) |
| if err != nil { |
| return ReleaseMilestones{}, err |
| } |
| nextV, err := nextVersion(currentVersion) |
| if err != nil { |
| return ReleaseMilestones{}, err |
| } |
| nextMilestone, err := m.Client.FetchMilestone(ctx, m.RepoOwner, m.RepoName, uppercaseVersion(nextV), true) |
| if err != nil { |
| return ReleaseMilestones{}, err |
| } |
| if kind == KindMajor { |
| // Create the first minor release milestone too. |
| firstMinor := majorVersion + ".1" |
| if err != nil { |
| return ReleaseMilestones{}, err |
| } |
| _, err = m.Client.FetchMilestone(ctx, m.RepoOwner, m.RepoName, uppercaseVersion(firstMinor), true) |
| if err != nil { |
| return ReleaseMilestones{}, err |
| } |
| } |
| return ReleaseMilestones{Current: currentMilestone, Next: nextMilestone}, nil |
| } |
| |
| func uppercaseVersion(version string) string { |
| return strings.Replace(version, "go", "Go", 1) |
| } |
| |
| // CheckBlockers returns an error if there are open release blockers in |
| // the current milestone. |
| func (m *MilestoneTasks) CheckBlockers(ctx *wf.TaskContext, milestones ReleaseMilestones, version string, kind ReleaseKind) error { |
| issues, err := m.Client.FetchMilestoneIssues(ctx, m.RepoOwner, m.RepoName, milestones.Current) |
| if err != nil { |
| return err |
| } |
| var blockers []string |
| for number, labels := range issues { |
| releaseBlocker := labels["release-blocker"] |
| switch { |
| case kind == KindBeta && strings.HasSuffix(version, "beta1") && labels["okay-after-beta1"], |
| kind == KindRC && strings.HasSuffix(version, "rc1") && labels["okay-after-rc1"]: |
| releaseBlocker = false |
| } |
| if releaseBlocker { |
| blockers = append(blockers, fmt.Sprintf("https://go.dev/issue/%v", number)) |
| } |
| } |
| sort.Strings(blockers) |
| if len(blockers) == 0 { |
| return nil |
| } |
| 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", |
| milestones.Current, strings.Join(blockers, "\n")) |
| return m.ApproveAction(ctx) |
| } |
| |
| // PushIssues updates issues to reflect a finished release. |
| // For major and minor releases, it moves issues to the next milestone and closes the current milestone. |
| // For pre-releases, it cleans up any "okay-after-..." labels in the current milestone that are done serving their purpose. |
| func (m *MilestoneTasks) PushIssues(ctx *wf.TaskContext, milestones ReleaseMilestones, version string, kind ReleaseKind) error { |
| issues, err := m.Client.FetchMilestoneIssues(ctx, m.RepoOwner, m.RepoName, milestones.Current) |
| if err != nil { |
| return err |
| } |
| ctx.Printf("Processing %d open issues in milestone %d.", len(issues), milestones.Current) |
| for issueNumber, labels := range issues { |
| var newLabels *[]string |
| var newMilestone *int |
| var actions []string // A short description of actions taken, for the log line. |
| removeLabel := func(name string) { |
| if !labels[name] { |
| return |
| } |
| newLabels = new([]string) |
| for label := range labels { |
| if label == name { |
| continue |
| } |
| *newLabels = append(*newLabels, label) |
| } |
| actions = append(actions, fmt.Sprintf("removed label %q", name)) |
| } |
| if kind == KindBeta && strings.HasSuffix(version, "beta1") { |
| removeLabel("okay-after-beta1") |
| } else if kind == KindRC && strings.HasSuffix(version, "rc1") { |
| removeLabel("okay-after-rc1") |
| } else if kind == KindMajor || kind == KindMinor { |
| newMilestone = &milestones.Next |
| actions = append(actions, fmt.Sprintf("pushed to milestone %d", milestones.Next)) |
| } |
| if newMilestone == nil && newLabels == nil { |
| ctx.Printf("Nothing to do for issue %d.", issueNumber) |
| continue |
| } |
| _, _, err := m.Client.EditIssue(ctx, m.RepoOwner, m.RepoName, issueNumber, &github.IssueRequest{ |
| Milestone: newMilestone, |
| Labels: newLabels, |
| }) |
| if err != nil { |
| return err |
| } |
| ctx.Printf("Updated issue %d: %s.", issueNumber, strings.Join(actions, ", ")) |
| } |
| if kind == KindMajor || kind == KindMinor { |
| _, _, err := m.Client.EditMilestone(ctx, m.RepoOwner, m.RepoName, milestones.Current, &github.Milestone{ |
| State: github.String("closed"), |
| }) |
| if err != nil { |
| return err |
| } |
| ctx.Printf("Closed milestone %d.", milestones.Current) |
| } |
| return nil |
| } |
| |
| // PingEarlyIssues pings early-in-cycle issues in the development major release milestone. |
| // This is done once at the opening of a release cycle, currently via a standalone workflow. |
| // |
| // develVersion is a value like 22 representing that Go 1.22 is the major version whose |
| // development has recently started, and whose early-in-cycle issues are to be pinged. |
| func (m *MilestoneTasks) PingEarlyIssues(ctx *wf.TaskContext, develVersion int, openTreeURL string) (result struct{}, _ error) { |
| milestoneName := fmt.Sprintf("Go1.%d", develVersion) |
| |
| gh, ok := m.Client.(*GitHubClient) |
| if !ok || gh.V4 == nil { |
| // TODO(go.dev/issue/58856): Decide if it's worth moving the GraphQL query/mutation |
| // into GitHubClientInterface. That kinda harms readability because GraphQL code is |
| // basically a flexible API call, so it's most readable when close to where they're |
| // used. This also depends on what kind of tests we'll want to use for this. |
| return struct{}{}, fmt.Errorf("no GitHub API v4 client") |
| } |
| |
| // Find all open early-in-cycle issues in the development major release milestone. |
| type issue struct { |
| ID githubv4.ID |
| Number int |
| Title string |
| |
| TimelineItems struct { |
| Nodes []struct { |
| IssueComment struct { |
| Author struct{ Login string } |
| Body string |
| } `graphql:"...on IssueComment"` |
| } |
| } `graphql:"timelineItems(since: $avoidDupSince, itemTypes: ISSUE_COMMENT, last: 100)"` |
| } |
| var earlyIssues []issue |
| milestoneNumber, err := m.Client.FetchMilestone(ctx, m.RepoOwner, m.RepoName, milestoneName, false) |
| if err != nil { |
| return struct{}{}, err |
| } |
| variables := map[string]interface{}{ |
| "repoOwner": githubv4.String(m.RepoOwner), |
| "repoName": githubv4.String(m.RepoName), |
| "avoidDupSince": githubv4.DateTime{Time: time.Now().Add(-30 * 24 * time.Hour)}, |
| "milestoneNumber": githubv4.String(fmt.Sprint(milestoneNumber)), // For some reason GitHub API v4 uses string type for milestone numbers. |
| "issueCursor": (*githubv4.String)(nil), |
| } |
| for { |
| var q struct { |
| Repository struct { |
| Issues struct { |
| Nodes []issue |
| PageInfo struct { |
| EndCursor githubv4.String |
| HasNextPage bool |
| } |
| } `graphql:"issues(first: 100, after: $issueCursor, filterBy: {states: OPEN, labels: \"early-in-cycle\", milestoneNumber: $milestoneNumber}, orderBy: {field: CREATED_AT, direction: ASC})"` |
| } `graphql:"repository(owner: $repoOwner, name: $repoName)"` |
| } |
| err := gh.V4.Query(ctx, &q, variables) |
| if err != nil { |
| return struct{}{}, err |
| } |
| earlyIssues = append(earlyIssues, q.Repository.Issues.Nodes...) |
| if !q.Repository.Issues.PageInfo.HasNextPage { |
| break |
| } |
| variables["issueCursor"] = githubv4.NewString(q.Repository.Issues.PageInfo.EndCursor) |
| } |
| |
| // Ping them. |
| ctx.Printf("Processing %d early-in-cycle issues in %s milestone (milestone number %d).", len(earlyIssues), milestoneName, milestoneNumber) |
| EarlyIssuesLoop: |
| for _, i := range earlyIssues { |
| for _, n := range i.TimelineItems.Nodes { |
| if n.IssueComment.Author.Login == "gopherbot" && strings.Contains(n.IssueComment.Body, "friendly reminder") { |
| ctx.Printf("Skipping issue %d, it was already pinged.", i.Number) |
| continue EarlyIssuesLoop |
| } |
| } |
| |
| // Post a comment. |
| const dryRun = false |
| if dryRun { |
| ctx.Printf("[dry run] Would've pinged issue %d (%.32s…).", i.Number, i.Title) |
| continue |
| } |
| err := m.Client.PostComment(ctx, i.ID, fmt.Sprintf("This issue is currently labeled as early-in-cycle for Go 1.%d.\n"+ |
| "That [time is now](%s), so a friendly reminder to look at it again.", develVersion, openTreeURL)) |
| if err != nil { |
| return struct{}{}, err |
| } |
| ctx.Printf("Pinged issue %d (%.32s…).", i.Number, i.Title) |
| time.Sleep(3 * time.Second) // Take a moment between pinging issues to avoid a high rate of addComment mutations. |
| } |
| |
| return struct{}{}, nil |
| } |
| |
| // GitHubClientInterface is a wrapper around the GitHub v3 and v4 APIs, for |
| // testing and dry-run support. |
| type GitHubClientInterface interface { |
| // FetchMilestone returns the number of the GitHub milestone with the specified name. |
| // If create is true, and the milestone doesn't exist, it will be created. |
| FetchMilestone(ctx context.Context, owner, repo, name string, create bool) (int, error) |
| |
| // FetchMilestoneIssues returns all the open issues in the specified milestone |
| // and their labels. |
| FetchMilestoneIssues(ctx context.Context, owner, repo string, milestoneID int) (map[int]map[string]bool, error) |
| |
| // See github.Client.Issues.Create. |
| CreateIssue(ctx context.Context, owner string, repo string, issue *github.IssueRequest) (*github.Issue, *github.Response, error) |
| |
| // See github.Client.Issues.Edit. |
| EditIssue(ctx context.Context, owner string, repo string, number int, issue *github.IssueRequest) (*github.Issue, *github.Response, error) |
| |
| // See github.Client.Issues.Get. |
| GetIssue(ctx context.Context, owner string, repo string, number int) (*github.Issue, *github.Response, error) |
| |
| // See github.Client.Issues.EditMilestone. |
| EditMilestone(ctx context.Context, owner string, repo string, number int, milestone *github.Milestone) (*github.Milestone, *github.Response, error) |
| |
| // PostComment creates a comment on a GitHub issue or pull request |
| // identified by the given GitHub Node ID. |
| PostComment(_ context.Context, id githubv4.ID, body string) error |
| |
| // See github.Client.Repositories.CreateRelease. |
| CreateRelease(ctx context.Context, owner, repo string, release *github.RepositoryRelease) (*github.RepositoryRelease, error) |
| |
| // UploadReleaseAsset uploads an fs.File to a GitHub release as a release |
| // asset. |
| // It uses NewUploadRequest as github.Client.Repositories.UploadReleaseAsset |
| // only supports uploading from an os.File. |
| // Parameters: |
| // - owner: The account owner of the repository. |
| // - repo: The name of the repository. |
| // - releaseID: The ID of the github release. |
| // - fileName: The name of the asset as it will appear in the release. |
| // - file: The content of the file to upload. |
| UploadReleaseAsset(ctx context.Context, owner, repo string, releaseID int64, fileName string, file fs.File) (*github.ReleaseAsset, error) |
| } |
| |
| type GitHubClient struct { |
| V3 *github.Client |
| V4 *githubv4.Client |
| } |
| |
| func (c *GitHubClient) FetchMilestone(ctx context.Context, owner, repo, name string, create bool) (int, error) { |
| n, found, err := findMilestone(ctx, c.V4, owner, repo, name) |
| if err != nil { |
| return 0, err |
| } |
| if found { |
| return n, nil |
| } else if !create { |
| return 0, fmt.Errorf("no milestone named %q found, and creation was disabled", name) |
| } |
| m, _, createErr := c.V3.Issues.CreateMilestone(ctx, owner, repo, &github.Milestone{ |
| Title: github.String(name), |
| }) |
| if createErr != nil { |
| return 0, fmt.Errorf("could not find an open milestone named %q and creating it failed: %v", name, createErr) |
| } |
| return *m.Number, nil |
| } |
| |
| func (c *GitHubClient) UploadReleaseAsset(ctx context.Context, owner, repo string, releaseID int64, fileName string, file fs.File) (*github.ReleaseAsset, error) { |
| // Query parameter "name" is used to determine the asset name. |
| // See details https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#upload-a-release-asset |
| u := fmt.Sprintf("repos/%s/%s/releases/%d/assets?name=%s", owner, repo, releaseID, url.QueryEscape(fileName)) |
| |
| stat, err := file.Stat() |
| if err != nil { |
| return nil, err |
| } |
| if stat.IsDir() { |
| return nil, errors.New("the asset to upload can't be a directory") |
| } |
| |
| req, err := c.V3.NewUploadRequest(u, file, stat.Size(), "") |
| if err != nil { |
| return nil, err |
| } |
| |
| asset := new(github.ReleaseAsset) |
| if _, err = c.V3.Do(ctx, req, asset); err != nil { |
| return nil, err |
| } |
| return asset, nil |
| } |
| |
| func (c *GitHubClient) CreateRelease(ctx context.Context, owner, repo string, release *github.RepositoryRelease) (*github.RepositoryRelease, error) { |
| release, _, err := c.V3.Repositories.CreateRelease(ctx, owner, repo, release) |
| return release, err |
| } |
| |
| func findMilestone(ctx context.Context, client *githubv4.Client, owner, repo, name string) (int, bool, error) { |
| var query struct { |
| Repository struct { |
| Milestones struct { |
| Nodes []struct { |
| Title string |
| Number int |
| State string |
| } |
| } `graphql:"milestones(first:10, query: $milestoneName)"` |
| } `graphql:"repository(owner: $repoOwner, name: $repoName)"` |
| } |
| if err := client.Query(ctx, &query, map[string]interface{}{ |
| "repoOwner": githubv4.String(owner), |
| "repoName": githubv4.String(repo), |
| "milestoneName": githubv4.String(name), |
| }); err != nil { |
| return 0, false, err |
| } |
| // The milestone query is case-insensitive and a partial match; we're okay |
| // with case variations but it needs to be a full match. |
| var open, closed []string |
| milestoneNumber := 0 |
| for _, m := range query.Repository.Milestones.Nodes { |
| if strings.ToLower(name) != strings.ToLower(m.Title) { |
| continue |
| } |
| if m.State == "OPEN" { |
| open = append(open, m.Title) |
| milestoneNumber = m.Number |
| } else { |
| closed = append(closed, m.Title) |
| } |
| } |
| // GitHub allows "go" and "Go" to exist at the same time. |
| // If there's any confusion, fail: we expect either one open milestone, |
| // or no matching milestones at all. |
| switch { |
| case len(open) == 1: |
| return milestoneNumber, true, nil |
| case len(open) > 1: |
| return 0, false, fmt.Errorf("multiple open milestones matching %q: %q", name, open) |
| // No open milestones. |
| case len(closed) == 0: |
| return 0, false, nil |
| case len(closed) > 0: |
| return 0, false, fmt.Errorf("no open milestones matching %q, but some closed: %q (re-open or delete?)", name, closed) |
| } |
| // The switch above is exhaustive. |
| panic(fmt.Errorf("unhandled case: open: %q closed: %q", open, closed)) |
| } |
| |
| func (c *GitHubClient) FetchMilestoneIssues(ctx context.Context, owner, repo string, milestoneID int) (map[int]map[string]bool, error) { |
| issues := map[int]map[string]bool{} |
| var query struct { |
| Repository struct { |
| Issues struct { |
| PageInfo struct { |
| EndCursor githubv4.String |
| HasNextPage bool |
| } |
| |
| Nodes []struct { |
| Number int |
| ID githubv4.ID |
| Title string |
| Labels struct { |
| PageInfo struct { |
| HasNextPage bool |
| } |
| Nodes []struct { |
| Name string |
| } |
| } `graphql:"labels(first:10)"` |
| } |
| } `graphql:"issues(first:100, after:$afterToken, filterBy:{states:OPEN, milestoneNumber:$milestoneNumber})"` |
| } `graphql:"repository(owner: $repoOwner, name: $repoName)"` |
| } |
| var afterToken *githubv4.String |
| more: |
| if err := c.V4.Query(ctx, &query, map[string]interface{}{ |
| "repoOwner": githubv4.String(owner), |
| "repoName": githubv4.String(repo), |
| "milestoneNumber": githubv4.String(fmt.Sprint(milestoneID)), |
| "afterToken": afterToken, |
| }); err != nil { |
| return nil, err |
| } |
| for _, issue := range query.Repository.Issues.Nodes { |
| if issue.Labels.PageInfo.HasNextPage { |
| return nil, fmt.Errorf("issue %v (#%v) has more than 10 labels", issue.Title, issue.Number) |
| } |
| labels := map[string]bool{} |
| for _, label := range issue.Labels.Nodes { |
| labels[label.Name] = true |
| } |
| issues[issue.Number] = labels |
| } |
| if query.Repository.Issues.PageInfo.HasNextPage { |
| afterToken = &query.Repository.Issues.PageInfo.EndCursor |
| goto more |
| } |
| return issues, nil |
| } |
| |
| func (c *GitHubClient) EditIssue(ctx context.Context, owner string, repo string, number int, issue *github.IssueRequest) (*github.Issue, *github.Response, error) { |
| return c.V3.Issues.Edit(ctx, owner, repo, number, issue) |
| } |
| |
| func (c *GitHubClient) CreateIssue(ctx context.Context, owner string, repo string, issue *github.IssueRequest) (*github.Issue, *github.Response, error) { |
| return c.V3.Issues.Create(ctx, owner, repo, issue) |
| } |
| |
| func (c *GitHubClient) GetIssue(ctx context.Context, owner string, repo string, number int) (*github.Issue, *github.Response, error) { |
| return c.V3.Issues.Get(ctx, owner, repo, number) |
| } |
| |
| func (c *GitHubClient) EditMilestone(ctx context.Context, owner string, repo string, number int, milestone *github.Milestone) (*github.Milestone, *github.Response, error) { |
| return c.V3.Issues.EditMilestone(ctx, owner, repo, number, milestone) |
| } |
| |
| func (c *GitHubClient) PostComment(ctx context.Context, id githubv4.ID, body string) error { |
| return c.V4.Mutate(ctx, new(struct { |
| AddComment struct { |
| ClientMutationID string // Unused; GraphQL doesn't allow for mutations to return nothing. |
| } `graphql:"addComment(input: $input)"` |
| }), githubv4.AddCommentInput{ |
| SubjectID: id, |
| Body: githubv4.String(body), |
| }, nil) |
| } |