cmd/gopherbot: add "convert wait-release topic to hashtag" task

The 'wait-release' hashtag has meaning for humans and release tooling.
The 'wait-release' topic can be easy to accidentally apply instead of
the hashtag. Add a task to correct such mistakes automatically.

Change-Id: I290e1b9046c9e18181e41a920c0c02ef9ae72439
Reviewed-on: https://go-review.googlesource.com/c/build/+/588195
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
Auto-Submit: Dmitri Shuralyov <dmitshur@golang.org>
Reviewed-by: Ian Lance Taylor <iant@google.com>
diff --git a/cmd/gopherbot/gopherbot.go b/cmd/gopherbot/gopherbot.go
index d019dc6..a1b27db 100644
--- a/cmd/gopherbot/gopherbot.go
+++ b/cmd/gopherbot/gopherbot.go
@@ -485,6 +485,7 @@
 	{"cl2issue", (*gopherbot).cl2issue},
 	{"congratulate new contributors", (*gopherbot).congratulateNewContributors},
 	{"un-wait CLs", (*gopherbot).unwaitCLs},
+	{"convert wait-release topic to hashtag", (*gopherbot).topicToHashtag},
 }
 
 // gardenIssues reports whether GopherBot should perform general issue
@@ -1627,6 +1628,31 @@
 	})
 }
 
+// topicToHashtag converts CLs with 'wait-release' topic
+// to the likely intended uses of 'wait-release' hashtag.
+func (b *gopherbot) topicToHashtag(ctx context.Context) error {
+	waitTopicCLs, err := b.gerrit.QueryChanges(ctx, "status:open topic:wait-release")
+	if err != nil {
+		return err
+	}
+	for _, cl := range waitTopicCLs {
+		if *dryRun {
+			log.Printf("[dry run] would replace 'wait-release' topic with hashtag on CL %d (%.32s…)", cl.ChangeNumber, cl.Subject)
+			continue
+		}
+		_, err := b.gerrit.AddHashtags(ctx, cl.ID, "wait-release")
+		if err != nil {
+			return err
+		}
+		err = b.gerrit.DeleteTopic(ctx, cl.ID)
+		if err != nil {
+			return err
+		}
+		log.Printf("https://go.dev/cl/%d: replaced 'wait-release' topic with hashtag", cl.ChangeNumber)
+	}
+	return nil
+}
+
 // onLatestCL checks whether cl's metadata is up to date with Gerrit's
 // upstream data and, if so, returns f(). If it's out of date, it does
 // nothing more and returns nil.
diff --git a/gerrit/gerrit.go b/gerrit/gerrit.go
index de7839b..f626395 100644
--- a/gerrit/gerrit.go
+++ b/gerrit/gerrit.go
@@ -659,7 +659,7 @@
 
 // HashtagsInput is the request body used when modifying a CL's hashtags.
 //
-// See https://gerrit-documentation.storage.googleapis.com/Documentation/2.15.1/rest-api-changes.html#hashtags-input
+// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#hashtags-input
 type HashtagsInput struct {
 	Add    []string `json:"add"`
 	Remove []string `json:"remove"`
@@ -669,7 +669,7 @@
 // and removing hashtags in one request. On success it returns the new
 // set of hashtags.
 //
-// See https://gerrit-documentation.storage.googleapis.com/Documentation/2.15.1/rest-api-changes.html#set-hashtags
+// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#set-hashtags
 func (c *Client) SetHashtags(ctx context.Context, changeID string, hashtags HashtagsInput) ([]string, error) {
 	var res []string
 	err := c.do(ctx, &res, "POST", fmt.Sprintf("/changes/%s/hashtags", changeID), reqBodyJSON{&hashtags})
@@ -688,13 +688,20 @@
 
 // GetHashtags returns a CL's current hashtags.
 //
-// See https://gerrit-documentation.storage.googleapis.com/Documentation/2.15.1/rest-api-changes.html#get-hashtags
+// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#get-hashtags
 func (c *Client) GetHashtags(ctx context.Context, changeID string) ([]string, error) {
 	var res []string
 	err := c.do(ctx, &res, "GET", fmt.Sprintf("/changes/%s/hashtags", changeID))
 	return res, err
 }
 
+// DeleteTopic deletes the topic of a change.
+//
+// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#delete-topic.
+func (c *Client) DeleteTopic(ctx context.Context, changeID string) error {
+	return c.do(ctx, nil, "DELETE", "/changes/"+changeID+"/topic", wantResStatus(http.StatusNoContent))
+}
+
 // AbandonChange abandons the given change.
 // For the API call, see https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#abandon-change
 // The changeID is https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id
diff --git a/internal/task/version.go b/internal/task/version.go
index a6c01ab..39b05eb 100644
--- a/internal/task/version.go
+++ b/internal/task/version.go
@@ -239,7 +239,7 @@
 func (t *VersionTasks) UnwaitWaitReleaseCLs(ctx *workflow.TaskContext) (result struct{}, _ error) {
 	waitingCLs, err := t.Gerrit.QueryChanges(ctx, "status:open hashtag:wait-release")
 	if err != nil {
-		return struct{}{}, nil
+		return struct{}{}, err
 	}
 	ctx.Printf("Processing %d open Gerrit CL with wait-release hashtag.", len(waitingCLs))
 	for _, cl := range waitingCLs {