cmd/gopherbot: add auto-submit functionality

If a CL has been labeled with "Auto-Submit", is submittable according
to Gerrit, has a positive TryBot-Result vote, and has no unresolved
comments then submit the change.

This requires adding a new gerrit.Client method for the CL submission
endpoint.

Updates golang/go#48021

Change-Id: I3d5dafd1ca25a3cac5a40d7e9a744ba12ab44cae
Reviewed-on: https://go-review.googlesource.com/c/build/+/341212
Trust: Roland Shoemaker <roland@golang.org>
Run-TryBot: Roland Shoemaker <roland@golang.org>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/cmd/gopherbot/gopherbot.go b/cmd/gopherbot/gopherbot.go
index 484fe66..0e26ffb 100644
--- a/cmd/gopherbot/gopherbot.go
+++ b/cmd/gopherbot/gopherbot.go
@@ -384,6 +384,7 @@
 	// Gerrit tasks are applied to all projects by default.
 	{"abandon scratch reviews", (*gopherbot).abandonScratchReviews},
 	{"assign reviewers to CLs", (*gopherbot).assignReviewersToCLs},
+	{"auto-submit CLs", (*gopherbot).autoSubmitCLs},
 
 	// Tasks that are specific to the golang/vscode-go repo.
 	{"set vscode-go milestones", (*gopherbot).setVSCodeGoMilestones},
@@ -2255,6 +2256,122 @@
 	return ids, count >= minHumans
 }
 
+// autoSubmitCLs submits CLs which are labelled "Auto-Submit", are submittable according to Gerrit,
+// have a positive TryBot-Result label, and have no unresolved comments.
+//
+// See golang.org/issue/48021.
+func (b *gopherbot) autoSubmitCLs(ctx context.Context) error {
+	// We only run this task if it was explicitly requested via
+	// the --only-run flag.
+	if *onlyRun == "" {
+		return nil
+	}
+
+	return b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
+		if gp.Server() != "go.googlesource.com" {
+			return nil
+		}
+		return gp.ForeachOpenCL(func(cl *maintner.GerritCL) error {
+			gc := gerritChange{gp.Project(), cl.Number}
+			if b.deletedChanges[gc] {
+				return nil
+			}
+
+			// Break out early (before making Gerrit API calls) if the Auto-Submit label
+			// hasn't been used at all in this CL.
+			var autosubmitPresent bool
+			for _, meta := range cl.Metas {
+				if strings.Contains(meta.Commit.Msg, "\nLabel: Auto-Submit") {
+					autosubmitPresent = true
+					break
+				}
+			}
+			if !autosubmitPresent {
+				return nil
+			}
+
+			// Skip this CL if there aren't Auto-Submit+1 and TryBot-Result+1 labels.
+			changeInfo, err := b.gerrit.GetChange(ctx, fmt.Sprint(cl.Number), gerrit.QueryChangesOpt{Fields: []string{"LABELS", "SUBMITTABLE"}})
+			if err != nil {
+				if httpErr, ok := err.(*gerrit.HTTPError); ok && httpErr.Res.StatusCode == http.StatusNotFound {
+					b.deletedChanges[gc] = true
+				}
+				log.Printf("Could not retrieve change %q: %v", gc.ID(), err)
+				return nil
+			}
+			if !(changeInfo.Labels["Auto-Submit"].Approved != nil && changeInfo.Labels["TryBot-Result"].Approved != nil) {
+				return nil
+			}
+			// NOTE: we might be able to skip this as well, since the revision action
+			// check will also cover this...
+			if !changeInfo.Submittable {
+				return nil
+			}
+
+			// Skip this CL if there are any unresolved comment threads.
+			comments, err := b.gerrit.ListChangeComments(ctx, fmt.Sprint(cl.Number))
+			if err != nil {
+				return err
+			}
+			for _, commentSet := range comments {
+				sort.Slice(commentSet, func(i, j int) bool {
+					return commentSet[i].Updated.Time().Before(commentSet[j].Updated.Time())
+				})
+				threads := make(map[string]bool)
+				for _, c := range commentSet {
+					id := c.ID
+					if c.InReplyTo != "" {
+						id = c.InReplyTo
+					}
+					threads[id] = *c.Unresolved
+				}
+				for _, unresolved := range threads {
+					if unresolved {
+						return nil
+					}
+				}
+			}
+
+			// We need to check the mergeability, as well as the submitability,
+			// as the latter doesn't take into account merge conflicts, just
+			// if the change satisfies the project submit rules.
+			//
+			// NOTE: this may now be redundant, since the revision action check
+			// below will also inherently checks mergeability, since the change
+			// cannot actually be submitted if there is a merge conflict. We
+			// may be able to just skip this entirely.
+			mi, err := b.gerrit.GetMergeable(ctx, fmt.Sprint(cl.Number), "current")
+			if err != nil {
+				return err
+			}
+			if !mi.Mergeable || mi.CommitMerged {
+				return nil
+			}
+
+			ra, err := b.gerrit.GetRevisionActions(ctx, fmt.Sprint(cl.Number), "current")
+			if err != nil {
+				return err
+			}
+			if ra["submit"] == nil || !ra["submit"].Enabled {
+				return nil
+			}
+
+			if *dryRun {
+				log.Printf("[dry-run] would've submitted CL https://golang.org/cl/%d ...", cl.Number)
+				return nil
+			}
+			log.Printf("submitting CL https://golang.org/cl/%d ...", cl.Number)
+
+			// TODO: if maintner isn't fast enough (or is too fast) and it re-runs this
+			// before the submission is noticed, we may run this more than once. This
+			// could be handled with a local cache of "recently submitted" changes to
+			// be ignored.
+			_, err = b.gerrit.SubmitChange(ctx, fmt.Sprint(cl.Number))
+			return err
+		})
+	})
+}
+
 // reviewerRe extracts the reviewer's Gerrit ID from a line that looks like:
 //
 //   Reviewer: Rebecca Stambler <16140@62eb7196-b449-3ce5-99f1-c037f21e1705>
diff --git a/gerrit/gerrit.go b/gerrit/gerrit.go
index b24f15a..326f2e9 100644
--- a/gerrit/gerrit.go
+++ b/gerrit/gerrit.go
@@ -333,7 +333,9 @@
 	Optional bool `json:"optional"`
 
 	// Fields set by LABELS field option:
+	Approved *AccountInfo `json:"approved"`
 
+	// Fields set by DETAILED_LABELS option:
 	All []ApprovalInfo `json:"all"`
 }
 
@@ -999,3 +1001,47 @@
 	err := c.do(ctx, &ais, "GET", "/groups/"+groupID+"/members")
 	return ais, err
 }
+
+// SubmitChange submits the given change.
+// For the API call, see https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submit-change
+// The changeID is https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id
+func (c *Client) SubmitChange(ctx context.Context, changeID string) (ChangeInfo, error) {
+	var change ChangeInfo
+	err := c.do(ctx, &change, "POST", "/changes/"+changeID+"/submit")
+	return change, err
+}
+
+// MergeableInfo contains information about the mergeability of a change.
+//
+// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#mergeable-info.
+type MergeableInfo struct {
+	SubmitType   string `json:"submit_type"`
+	Strategy     string `json:"strategy"`
+	Mergeable    bool   `json:"mergeable"`
+	CommitMerged bool   `json:"commit_merged"`
+}
+
+// GetMergeable retrieves mergeability information for a change at a specific revision.
+func (c *Client) GetMergeable(ctx context.Context, changeID, revision string) (MergeableInfo, error) {
+	var mergeable MergeableInfo
+	err := c.do(ctx, &mergeable, "GET", "/changes/"+changeID+"/revisions/"+revision+"/mergeable")
+	return mergeable, err
+}
+
+// ActionInfo contains information about actions a client can make to
+// maniuplate a resource.
+//
+// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#action-info.
+type ActionInfo struct {
+	Method  string `json:"method"`
+	Label   string `json:"label"`
+	Title   string `json:"title"`
+	Enabled bool   `json:"enabled"`
+}
+
+// GetRevisionActions retrieves revision actions.
+func (c *Client) GetRevisionActions(ctx context.Context, changeID, revision string) (map[string]*ActionInfo, error) {
+	var actions map[string]*ActionInfo
+	err := c.do(ctx, &actions, "GET", "/changes/"+changeID+"/revisions/"+revision+"/actions")
+	return actions, err
+}