| // Copyright 2017 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 main |
| |
| import ( |
| "context" |
| "errors" |
| "fmt" |
| "io/ioutil" |
| "log" |
| "net/http" |
| "os" |
| "path/filepath" |
| "strconv" |
| "strings" |
| |
| "github.com/google/go-github/github" |
| "golang.org/x/build/maintner" |
| "golang.org/x/build/maintner/godata" |
| "golang.org/x/oauth2" |
| ) |
| |
| const ( |
| projectOwner = "golang" |
| projectRepo = "go" |
| ) |
| |
| var githubClient *github.Client |
| |
| // GitHub personal access token, from https://github.com/settings/applications. |
| var githubAuthToken string |
| |
| var goRepo *maintner.GitHubRepo |
| |
| func loadMaintner() { |
| corpus, err := godata.Get(context.Background()) |
| if err != nil { |
| log.Fatal("failed to load maintner data:", err) |
| } |
| goRepo = corpus.GitHub().Repo(projectOwner, projectRepo) |
| } |
| |
| func loadGithubAuth() { |
| const short = ".github-issue-token" |
| filename := filepath.Clean(os.Getenv("HOME") + "/" + short) |
| shortFilename := filepath.Clean("$HOME/" + short) |
| data, err := ioutil.ReadFile(filename) |
| if err != nil { |
| log.Fatal("reading token: ", err, "\n\n"+ |
| "Please create a personal access token at https://github.com/settings/tokens/new\n"+ |
| "and write it to ", shortFilename, " to use this program.\n"+ |
| "** The token only needs the public_repo scope. **\n"+ |
| "The benefit of using a personal access token over using your GitHub\n"+ |
| "password directly is that you can limit its use and revoke it at any time.\n\n") |
| } |
| fi, err := os.Stat(filename) |
| if err != nil { |
| log.Fatalln("reading token:", err) |
| } |
| if fi.Mode()&0077 != 0 { |
| log.Fatalf("reading token: %s mode is %#o, want %#o", shortFilename, fi.Mode()&0777, fi.Mode()&0700) |
| } |
| githubAuthToken = strings.TrimSpace(string(data)) |
| t := &oauth2.Transport{ |
| Source: &tokenSource{AccessToken: githubAuthToken}, |
| } |
| githubClient = github.NewClient(&http.Client{Transport: t}) |
| } |
| |
| // releaseStatusTitle returns the title of the release status issue |
| // for the given milestone. |
| // If you change this function, releasebot will not be able to find an |
| // existing tracking issue using the old name and will create a new one. |
| func (w *Work) releaseStatusTitle() string { |
| return "all: " + strings.Replace(w.Version, "go", "Go ", -1) + " release status" |
| } |
| |
| type tokenSource oauth2.Token |
| |
| func (t *tokenSource) Token() (*oauth2.Token, error) { |
| return (*oauth2.Token)(t), nil |
| } |
| |
| func (w *Work) findOrCreateReleaseIssue() { |
| w.log.Printf("Release status issue title: %q", w.releaseStatusTitle()) |
| if dryRun { |
| return |
| } |
| if w.ReleaseIssue == 0 { |
| title := w.releaseStatusTitle() |
| body := fmt.Sprintf("Issue tracking the %s release by releasebot.", w.Version) |
| num, err := w.createGitHubIssue(title, body) |
| if err != nil { |
| w.log.Panic(err) |
| } |
| w.ReleaseIssue = num |
| w.log.Printf("Release status issue: https://golang.org/issue/%d", num) |
| } |
| } |
| |
| // createGitHubIssue creates an issue in the release milestone and returns its number. |
| func (w *Work) createGitHubIssue(title, msg string) (int, error) { |
| if dryRun { |
| return 0, errors.New("attempted write operation in dry-run mode") |
| } |
| var dup int |
| goRepo.ForeachIssue(func(gi *maintner.GitHubIssue) error { |
| if gi.Title == title { |
| dup = int(gi.Number) |
| return errors.New("stop iteration") |
| } |
| return nil |
| }) |
| if dup != 0 { |
| return dup, nil |
| } |
| opts := &github.IssueListByRepoOptions{ |
| State: "all", |
| ListOptions: github.ListOptions{PerPage: 100}, |
| } |
| if !w.BetaRelease && !w.RCRelease { |
| opts.Milestone = strconv.Itoa(int(w.Milestone.Number)) |
| } |
| is, _, err := githubClient.Issues.ListByRepo(context.TODO(), "golang", "go", opts) |
| if err != nil { |
| return 0, err |
| } |
| for _, i := range is { |
| if i.GetTitle() == title { |
| // Dup. |
| return i.GetNumber(), nil |
| } |
| } |
| copts := &github.IssueRequest{ |
| Title: github.String(title), |
| Body: github.String(msg), |
| } |
| if !w.BetaRelease && !w.RCRelease { |
| copts.Milestone = github.Int(int(w.Milestone.Number)) |
| } |
| i, _, err := githubClient.Issues.Create(context.TODO(), "golang", "go", copts) |
| return i.GetNumber(), err |
| } |
| |
| // pushIssues moves open issues to the milestone of the next release of the same kind, |
| // creating the milestone if it doesn't already exist. |
| // For major releases, it's the milestone of the next major release (e.g., 1.14 → 1.15). |
| // For minor releases, it's the milestone of the next minor release (e.g., 1.14.1 → 1.14.2). |
| // For other release types, it does nothing. |
| // |
| // For major releases, it also creates the first minor release milestone if it doesn't already exist. |
| func (w *Work) pushIssues() { |
| if w.BetaRelease || w.RCRelease { |
| // Nothing to do. |
| return |
| } |
| |
| // Get the milestone for the next release. |
| var nextMilestone *github.Milestone |
| nextV, err := nextVersion(w.Version) |
| if err != nil { |
| w.logError("error determining next version: %v", err) |
| return |
| } |
| nextMilestone, err = w.findOrCreateMilestone(nextV) |
| if err != nil { |
| w.logError("error finding or creating %s, the next GitHub milestone after release %s: %v", nextV, w.Version, err) |
| return |
| } |
| |
| // For major releases (go1.X), also create the first minor release milestone (go1.X.1). See issue 44404. |
| if strings.Count(w.Version, ".") == 1 { |
| firstMinor := w.Version + ".1" |
| _, err := w.findOrCreateMilestone(firstMinor) |
| if err != nil { |
| // Log this error, but continue executing the rest of the task. |
| w.logError("error finding or creating %s, the first minor release GitHub milestone after major release %s: %v", firstMinor, w.Version, err) |
| } |
| } |
| |
| if err := goRepo.ForeachIssue(func(gi *maintner.GitHubIssue) error { |
| if gi.Milestone == nil || gi.Milestone.ID != w.Milestone.ID { |
| return nil |
| } |
| if gi.Number == int32(w.ReleaseIssue) { |
| return nil |
| } |
| if gi.Closed { |
| return nil |
| } |
| w.log.Printf("changing milestone of issue %d to %s", gi.Number, nextMilestone.GetTitle()) |
| if dryRun { |
| return nil |
| } |
| _, _, err := githubClient.Issues.Edit(context.TODO(), projectOwner, projectRepo, int(gi.Number), &github.IssueRequest{ |
| Milestone: github.Int(nextMilestone.GetNumber()), |
| }) |
| if err != nil { |
| return fmt.Errorf("#%d: %s", gi.Number, err) |
| } |
| return nil |
| }); err != nil { |
| w.logError("error moving issues to the next minor release: %v", err) |
| return |
| } |
| } |
| |
| // findOrCreateMilestone finds or creates a GitHub milestone corresponding |
| // to the specified Go version. This is done via the GitHub API, using githubClient. |
| // If the milestone exists but isn't open, an error is returned. |
| func (w *Work) findOrCreateMilestone(version string) (*github.Milestone, error) { |
| // Look for an existing open milestone corresponding to version, |
| // and return it if found. |
| for opt := (&github.MilestoneListOptions{ListOptions: github.ListOptions{PerPage: 100}}); ; { |
| ms, resp, err := githubClient.Issues.ListMilestones(context.Background(), projectOwner, projectRepo, opt) |
| if err != nil { |
| return nil, err |
| } |
| for _, m := range ms { |
| if strings.ToLower(m.GetTitle()) == version { |
| // Found an existing milestone. |
| return m, nil |
| } |
| } |
| if resp.NextPage == 0 { |
| break |
| } |
| opt.Page = resp.NextPage |
| } |
| |
| // Create a new milestone. |
| // For historical reasons, Go milestone titles use a capital "Go1.n" format, |
| // in contrast to go versions which are like "go1.n". Do the same here. |
| title := strings.Replace(version, "go", "Go", 1) |
| w.log.Printf("creating milestone titled %q", title) |
| if dryRun { |
| return &github.Milestone{Title: github.String(title)}, nil |
| } |
| m, _, err := githubClient.Issues.CreateMilestone(context.Background(), projectOwner, projectRepo, &github.Milestone{ |
| Title: github.String(title), |
| }) |
| if e := (*github.ErrorResponse)(nil); errors.As(err, &e) && e.Response != nil && e.Response.StatusCode == http.StatusUnprocessableEntity && len(e.Errors) == 1 && e.Errors[0].Code == "already_exists" { |
| // We'll run into an already_exists error here if the milestone exists, |
| // but it wasn't found in the loop above because the milestone isn't open. |
| // That shouldn't happen under normal circumstances, so if it does, |
| // let humans figure out how to best deal with it. |
| return nil, errors.New("a closed milestone with the same title already exists") |
| } else if err != nil { |
| return nil, err |
| } |
| return m, nil |
| } |
| |
| // closeMilestone closes the milestone for the current release. |
| func (w *Work) closeMilestone() { |
| w.log.Printf("closing milestone %s", w.Milestone.Title) |
| if dryRun { |
| return |
| } |
| closed := "closed" |
| _, _, err := githubClient.Issues.EditMilestone(context.TODO(), projectOwner, projectRepo, int(w.Milestone.Number), &github.Milestone{ |
| State: &closed, |
| }) |
| if err != nil { |
| w.logError("closing milestone: %v", err) |
| } |
| |
| } |
| |
| // removeOkayAfterBeta1 cleans up the okay-after-beta1 label after the beta1 release is out. |
| func (w *Work) removeOkayAfterBeta1() { |
| if !w.BetaRelease || !strings.HasSuffix(w.Version, "beta1") { |
| // Nothing to do. |
| return |
| } |
| |
| if err := goRepo.ForeachIssue(func(gi *maintner.GitHubIssue) error { |
| if gi.Milestone == nil || gi.Milestone.ID != w.Milestone.ID { |
| return nil |
| } |
| if gi.Number == int32(w.ReleaseIssue) { |
| return nil |
| } |
| if gi.Closed || !gi.HasLabel("okay-after-beta1") { |
| return nil |
| } |
| w.log.Printf("removing okay-after-beta1 label in issue %d", gi.Number) |
| if dryRun { |
| return nil |
| } |
| _, err := githubClient.Issues.RemoveLabelForIssue(context.Background(), |
| projectOwner, projectRepo, int(gi.Number), "okay-after-beta1") |
| if err != nil { |
| return fmt.Errorf("#%d: %s", gi.Number, err) |
| } |
| return nil |
| }); err != nil { |
| w.logError("error removing okay-after-beta1 label from issues in current milestone: %v", err) |
| return |
| } |
| } |
| |
| const githubCommentCharacterLimit = 65536 // discovered in golang.org/issue/45998 |
| |
| func postGithubComment(number int, body string) error { |
| if dryRun { |
| return errors.New("attempted write operation in dry-run mode") |
| } |
| _, _, err := githubClient.Issues.CreateComment(context.TODO(), projectOwner, projectRepo, number, &github.IssueComment{ |
| Body: &body, |
| }) |
| return err |
| } |