cmd/releasebot: create GitHub milestones for next release

The current release process relies on humans to create GitHub milestones
for future Go releases. releasebot includes automated checks that detect
when that step is forgotten. CL 294249 included one of those checks, and
its commit message mentioned:

	(Future release process improvements may include automatically making
	the milestone. That is better suited to be in scope of golang/go#40279.)

This is the release process improvement that automates making milestones.

For golang/go#40279.

Change-Id: I8e02aff6714a5cf2de4ee4bcfe98aaf68abb0cd4
Reviewed-on: https://go-review.googlesource.com/c/build/+/354758
Run-TryBot: Dmitri Shuralyov <dmitshur@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
Reviewed-by: Alexander Rakoczy <alex@golang.org>
Trust: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/cmd/releasebot/github.go b/cmd/releasebot/github.go
index 4cd2f9a..cbced62 100644
--- a/cmd/releasebot/github.go
+++ b/cmd/releasebot/github.go
@@ -149,16 +149,42 @@
 	return i.GetNumber(), err
 }
 
-// pushIssues moves open issues to the milestone of the next release of the same kind.
+// 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
@@ -170,12 +196,12 @@
 		if gi.Closed && !w.Security {
 			return nil
 		}
-		w.log.Printf("changing milestone of issue %d to %s", gi.Number, w.NextMilestone.Title)
+		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(int(w.NextMilestone.Number)),
+			Milestone: github.Int(nextMilestone.GetNumber()),
 		})
 		if err != nil {
 			return fmt.Errorf("#%d: %s", gi.Number, err)
@@ -187,6 +213,52 @@
 	}
 }
 
+// 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)
diff --git a/cmd/releasebot/main.go b/cmd/releasebot/main.go
index 64e790a..00aa4d6 100644
--- a/cmd/releasebot/main.go
+++ b/cmd/releasebot/main.go
@@ -166,30 +166,12 @@
 	// Select release targets for this Go version.
 	w.ReleaseTargets = matchTargets(w.Version)
 
-	// Find milestones.
+	// Find milestone.
 	var err error
 	w.Milestone, err = getMilestone(w.Version)
 	if err != nil {
 		log.Fatalf("cannot find the GitHub milestone for release %s: %v", w.Version, err)
 	}
-	if !w.BetaRelease && !w.RCRelease {
-		nextV, err := nextVersion(w.Version)
-		if err != nil {
-			log.Fatalln("nextVersion:", err)
-		}
-		w.NextMilestone, err = getMilestone(nextV)
-		if err != nil {
-			log.Fatalf("cannot find %s, the next GitHub milestone after release %s: %v", nextV, w.Version, err)
-		}
-	}
-	// For major releases (go1.X), also check the "create first minor release milestone"
-	// step in the release process wasn't accidentally missed. See issue 44404.
-	if !w.BetaRelease && !w.RCRelease && strings.Count(w.Version, ".") == 1 {
-		firstMinor := w.Version + ".1"
-		if _, err := getMilestone(firstMinor); err != nil {
-			log.Fatalf("cannot find %s, the first minor release GitHub milestone after major release %s: %v", firstMinor, w.Version, err)
-		}
-	}
 
 	w.doRelease()
 }
@@ -325,11 +307,6 @@
 	ReleaseInfo map[string]*ReleaseInfo // map and info protected by releaseMu
 
 	Milestone *maintner.GitHubMilestone // Milestone for the current release.
-	// NextMilestone is the milestone of the next release of the same kind.
-	// 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's unset.
-	NextMilestone *maintner.GitHubMilestone
 }
 
 // ReleaseInfo describes a release build for a specific target.