cmd/gopherbot: add task to ping "early-in-cycle" issues

There are two tasks we regularly do when reopening the tree for the
next major Go version development: remove "wait-release" hashtags
from CLs, and ping issues with "early-in-cycle" label.

The former was automated in CL 129817. The latter is automated here.
Maintner is used to find issues, and a GitHub API client is used to
post comments.

The Go 1.17 cycle was used as an opportunity to test this approach.
It worked as expected; no unexpected problems were found.

Like the unwait-release task, this is run manually via -only-run flag.
In the future we may want to factor both of these tasks out of
gopherbot and into a place that can be used by release automation.
But this seems like the most sensible place to add it initially.

Add a brief sleep in the hot loop for tasks that are invoked
manually. This is to appease humans and can modified at will.
It's unlikely to remain needed when these tasks are automated further.

For golang/go#40279.

Change-Id: I70c4eb7e98551d610f1518d1599661db21e2f271
Reviewed-on: https://go-review.googlesource.com/c/build/+/300570
Run-TryBot: Dmitri Shuralyov <dmitshur@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Trust: Dmitri Shuralyov <dmitshur@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
diff --git a/cmd/gopherbot/gopherbot.go b/cmd/gopherbot/gopherbot.go
index 5a474c8..0dc1a2d 100644
--- a/cmd/gopherbot/gopherbot.go
+++ b/cmd/gopherbot/gopherbot.go
@@ -65,6 +65,7 @@
 	needsDecisionID      = 373401956
 	needsFixID           = 373399998
 	needsInvestigationID = 373402289
+	earlyInCycleID       = 626114143
 )
 
 // Label names (that are used in multiple places).
@@ -355,6 +356,7 @@
 	// Tasks that are specific to the golang/go repo.
 	{"kicktrain", (*gopherbot).getOffKickTrain},
 	{"unwait-release", (*gopherbot).unwaitRelease},
+	{"ping-early-issues", (*gopherbot).pingEarlyIssues},
 	{"label build issues", (*gopherbot).labelBuildIssues},
 	{"label mobile issues", (*gopherbot).labelMobileIssues},
 	{"label tools issues", (*gopherbot).labelToolsIssues},
@@ -792,6 +794,8 @@
 			log.Printf("[dry run] would remove hashtag 'wait-release' from CL %d", ci.ChangeNumber)
 			continue
 		}
+		log.Printf("https://golang.org/cl/%d: removing wait-release", ci.ChangeNumber)
+		time.Sleep(3 * time.Second) // Take a moment between updating CLs, since a human will be running this task manually.
 		_, err := b.gerrit.SetHashtags(ctx, ci.ID, gerrit.HashtagsInput{
 			Add:    []string{"ex-wait-release"},
 			Remove: []string{"wait-release"},
@@ -800,11 +804,55 @@
 			log.Printf("https://golang.org/cl/%d: modifying hash tags: %v", ci.ChangeNumber, err)
 			return err
 		}
-		log.Printf("https://golang.org/cl/%d: removed wait-release", ci.ChangeNumber)
 	}
 	return nil
 }
 
+// pingEarlyIssues pings early-in-cycle issues in the next major release milestone.
+// This is run manually (with --only-run) at the opening of a release cycle.
+func (b *gopherbot) pingEarlyIssues(ctx context.Context) error {
+	// We only run this task if it was explicitly requested via
+	// the --only-run flag.
+	if *onlyRun == "" {
+		return nil
+	}
+
+	// Compute nextMajor, a value like "1.17" representing that Go 1.17
+	// is the next major version (the version whose development just started).
+	majorReleases, err := b.getMajorReleases(ctx)
+	if err != nil {
+		return err
+	}
+	nextMajor := majorReleases[len(majorReleases)-1]
+
+	// The message posted in this task links to an announcement that the tree is open
+	// for general Go 1.x development. Update the openTreeURLs map appropriately when
+	// running this task.
+	openTreeURLs := map[string]string{
+		"1.17": "https://groups.google.com/g/golang-dev/c/VNJFUxHWLHo/m/PBmGdYqoAAAJ",
+	}
+	if url, ok := openTreeURLs[nextMajor]; !ok {
+		return fmt.Errorf("openTreeURLs[%q] is missing a value, please fill it in", nextMajor)
+	} else if !strings.HasPrefix(url, "https://groups.google.com/g/golang-dev/c/") {
+		return fmt.Errorf("openTreeURLs[%q] is %q, which doesn't begin with the usual prefix, so please double-check that the URL is correct", nextMajor, url)
+	}
+
+	return b.gorepo.ForeachIssue(func(gi *maintner.GitHubIssue) error {
+		if gi.NotExist || gi.Closed || gi.PullRequest || !gi.HasLabelID(earlyInCycleID) || gi.Milestone.Title != "Go"+nextMajor {
+			return nil
+		}
+		if *dryRun {
+			log.Printf("[dry run] would ping early-in-cycle issue %d", gi.Number)
+			return nil
+		}
+		log.Printf("pinging early-in-cycle issue %d", gi.Number)
+		time.Sleep(3 * time.Second) // Take a moment between pinging issues, since a human will be running this task manually.
+		msg := fmt.Sprintf("This issue is currently labeled as early-in-cycle for Go %s.\n"+
+			"That [time is now](%s), so a friendly reminder to look at it again.", nextMajor, openTreeURLs[nextMajor])
+		return b.addGitHubComment(ctx, b.gorepo, gi.Number, msg)
+	})
+}
+
 // freezeOldIssues locks any issue that's old and closed.
 // (Otherwise people find ancient bugs via searches and start asking questions
 // into a void and it's sad for everybody.)
@@ -1472,6 +1520,9 @@
 
 // getMajorReleases returns the two most recent major Go 1.x releases, and
 // the next upcoming release, sorted and formatted like []string{"1.9", "1.10", "1.11"}.
+//
+// The data returned is fetched from Maintner Service occasionally
+// and cached for some time.
 func (b *gopherbot) getMajorReleases(ctx context.Context) ([]string, error) {
 	b.releases.Lock()
 	defer b.releases.Unlock()