cmd/coordinator: resolve stale TryBot threads

Also, respond to the most recent thread even if there are multiple ones
for the same commit.

This was way harder than it should be.

Change-Id: Id6868b267c019cd70e504310ecf9c79dac29a17b
Reviewed-on: https://go-review.googlesource.com/c/build/+/317971
Run-TryBot: Filippo Valsorda <filippo@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Trust: Filippo Valsorda <filippo@golang.org>
Trust: Katie Hockman <katie@golang.org>
Reviewed-by: Katie Hockman <katie@golang.org>
diff --git a/cmd/coordinator/coordinator.go b/cmd/coordinator/coordinator.go
index 9c44088..715557c 100644
--- a/cmd/coordinator/coordinator.go
+++ b/cmd/coordinator/coordinator.go
@@ -1396,6 +1396,73 @@
 	return "autogenerated:trybots~" + s
 }
 
+func isTryBotsTag(s string) bool {
+	return strings.HasPrefix(s, "autogenerated:trybots~")
+}
+
+// A commentThread is a thread of Gerrit comments.
+type commentThread struct {
+	// root is the first comment in the thread.
+	root gerrit.CommentInfo
+	// thread is a list of all the comments in the thread, including the root,
+	// sorted chronologically.
+	thread []gerrit.CommentInfo
+	// unresolved is the thread unresolved state, based on the last comment.
+	unresolved bool
+}
+
+// listPatchSetThreads returns a list of PATCHSET_LEVEL comment threads, sorted
+// by the time at which they were started.
+func listPatchSetThreads(gerritClient *gerrit.Client, changeID string) ([]*commentThread, error) {
+	comments, err := gerritClient.ListChangeComments(context.Background(), changeID)
+	if err != nil {
+		return nil, err
+	}
+	patchSetComments := comments["/PATCHSET_LEVEL"]
+	if len(patchSetComments) == 0 {
+		return nil, nil
+	}
+
+	// The API doesn't sort comments chronologically, but "the state of
+	// resolution of a comment thread is stored in the last comment in that
+	// thread chronologically", so first of all sort them by time.
+	sort.Slice(patchSetComments, func(i, j int) bool {
+		return patchSetComments[i].Updated.Time().Before(patchSetComments[j].Updated.Time())
+	})
+
+	// roots is a map of message IDs to their thread root.
+	roots := make(map[string]string)
+	threads := make(map[string]*commentThread)
+	var result []*commentThread
+	for _, c := range patchSetComments {
+		if c.InReplyTo == "" {
+			roots[c.ID] = c.ID
+			threads[c.ID] = &commentThread{
+				root:       c,
+				thread:     []gerrit.CommentInfo{c},
+				unresolved: *c.Unresolved,
+			}
+			if c.Unresolved != nil {
+				threads[c.ID].unresolved = *c.Unresolved
+			}
+			result = append(result, threads[c.ID])
+			continue
+		}
+
+		root, ok := roots[c.InReplyTo]
+		if !ok {
+			return nil, fmt.Errorf("%s has no parent", c.ID)
+		}
+		roots[c.ID] = root
+		threads[root].thread = append(threads[root].thread, c)
+		if c.Unresolved != nil {
+			threads[root].unresolved = *c.Unresolved
+		}
+	}
+
+	return result, nil
+}
+
 func (ts *trySet) statusPage() string {
 	return "https://farmer.golang.org/try?commit=" + ts.Commit[:8]
 }
@@ -1417,15 +1484,42 @@
 		}
 	}
 
-	gerritClient := pool.NewGCEConfiguration().GerritClient()
-	ctx := context.Background()
 	unresolved := true
-	if err := gerritClient.SetReview(ctx, ts.ChangeTriple(), ts.Commit, gerrit.ReviewInput{
+	ri := gerrit.ReviewInput{
 		Tag: tryBotsTag("beginning"),
 		Comments: map[string][]gerrit.CommentInput{
 			"/PATCHSET_LEVEL": {{Message: msg, Unresolved: &unresolved}},
 		},
-	}); err != nil {
+	}
+
+	// Mark as resolved old TryBot threads that don't have human comments on them.
+	gerritClient := pool.NewGCEConfiguration().GerritClient()
+	if patchSetThreads, err := listPatchSetThreads(gerritClient, ts.ChangeTriple()); err == nil {
+		for _, t := range patchSetThreads {
+			if !t.unresolved {
+				continue
+			}
+			hasHumanComments := false
+			for _, c := range t.thread {
+				if !isTryBotsTag(c.Tag) {
+					hasHumanComments = true
+					break
+				}
+			}
+			if hasHumanComments {
+				continue
+			}
+			unresolved := false
+			ri.Comments["/PATCHSET_LEVEL"] = append(ri.Comments["/PATCHSET_LEVEL"], gerrit.CommentInput{
+				Message:    "Superseded.",
+				Unresolved: &unresolved,
+			})
+		}
+	} else {
+		log.Printf("Error getting Gerrit threads on %s: %v", ts.ChangeTriple(), err)
+	}
+
+	if err := gerritClient.SetReview(context.Background(), ts.ChangeTriple(), ts.Commit, ri); err != nil {
 		log.Printf("Error leaving Gerrit comment on %s: %v", ts.Commit[:8], err)
 	}
 }
@@ -1624,15 +1718,14 @@
 
 	var inReplyTo string
 	gerritClient := pool.NewGCEConfiguration().GerritClient()
-	ctx := context.Background()
-	if comments, err := gerritClient.ListChangeComments(ctx, ts.ChangeTriple()); err == nil {
-		for _, ci := range comments["/PATCHSET_LEVEL"] {
-			if strings.Contains(ci.Message, ts.statusPage()) {
-				inReplyTo = ci.ID
+	if patchSetThreads, err := listPatchSetThreads(gerritClient, ts.ChangeTriple()); err == nil {
+		for _, t := range patchSetThreads {
+			if t.root.Tag == tryBotsTag("beginning") && strings.Contains(t.root.Message, ts.statusPage()) {
+				inReplyTo = t.root.ID
 			}
 		}
 	} else {
-		log.Printf("Error getting Gerrit comments on %s: %v", ts.ChangeTriple(), err)
+		log.Printf("Error getting Gerrit threads on %s: %v", ts.ChangeTriple(), err)
 	}
 
 	// Mark resolved if TryBots are happy.
@@ -1653,7 +1746,7 @@
 			"TryBot-Result": gerritScore,
 		}
 	}
-	if err := gerritClient.SetReview(ctx, ts.ChangeTriple(), ts.Commit, ri); err != nil {
+	if err := gerritClient.SetReview(context.Background(), ts.ChangeTriple(), ts.Commit, ri); err != nil {
 		log.Printf("Error leaving Gerrit comment on %s: %v", ts.Commit[:8], err)
 	}
 }
diff --git a/cmd/coordinator/coordinator_test.go b/cmd/coordinator/coordinator_test.go
index 52442fc..3d83d71 100644
--- a/cmd/coordinator/coordinator_test.go
+++ b/cmd/coordinator/coordinator_test.go
@@ -21,6 +21,7 @@
 
 	"golang.org/x/build/buildenv"
 	"golang.org/x/build/dashboard"
+	"golang.org/x/build/gerrit"
 	"golang.org/x/build/internal/buildgo"
 	"golang.org/x/build/internal/coordinator/pool"
 	"golang.org/x/build/maintner/maintnerd/apipb"
@@ -422,3 +423,38 @@
 		}
 	}
 }
+
+// listPatchSetThreadsResponse is the response to
+// https://go-review.googlesource.com/changes/go~master~I92400996cb051ab30e99bfffafd91ff32a1e7087/comments
+var listPatchSetThreadsResponse = []byte(`)]}'
+{"/PATCHSET_LEVEL":[{"author":{"_account_id":5976,"name":"Go Bot","email":"gobot@golang.org","tags":["SERVICE_USER"]},"tag":"autogenerated:trybots~beginning","change_message_id":"af128c803aa192eb5c191c1567713f5b123d3adb","unresolved":true,"patch_set":1,"id":"aaf7aa39_658707c2","updated":"2021-04-27 18:20:09.000000000","message":"SlowBots beginning. Status page: https://farmer.golang.org/try?commit\u003d39ad506d","commit_id":"39ad506d874d4711015184f52585b4215c9b84cc"},{"author":{"_account_id":5976,"name":"Go Bot","email":"gobot@golang.org","tags":["SERVICE_USER"]},"tag":"autogenerated:trybots~beginning","change_message_id":"a00ee30c652a61afeb5ba7657e5823b2d56159ac","unresolved":true,"patch_set":1,"id":"cb0c9011_d26d6550","updated":"2021-04-27 07:10:41.000000000","message":"SlowBots beginning. Status page: https://farmer.golang.org/try?commit\u003d39ad506d","commit_id":"39ad506d874d4711015184f52585b4215c9b84cc"},{"author":{"_account_id":6365,"name":"Bryan C. Mills","email":"bcmills@google.com"},"change_message_id":"c3dc9da7c814efe691876649d8b1acd086d34921","unresolved":false,"patch_set":1,"id":"da1249e7_bc007148","updated":"2021-04-27 07:10:22.000000000","message":"TRY\u003dlongtest","commit_id":"39ad506d874d4711015184f52585b4215c9b84cc"},{"author":{"_account_id":6365,"name":"Bryan C. Mills","email":"bcmills@google.com"},"change_message_id":"1908c5ae7cd3fa1adc7579bfb4fa0798a33dafd2","unresolved":false,"patch_set":1,"id":"043375d0_558208b0","in_reply_to":"50a54b3c_f95f3567","updated":"2021-04-27 18:32:56.000000000","message":"#41863","commit_id":"39ad506d874d4711015184f52585b4215c9b84cc"},{"author":{"_account_id":6365,"name":"Bryan C. Mills","email":"bcmills@google.com"},"change_message_id":"061f7a6231e09027e96b7c81093b32db9cd6a6f3","unresolved":false,"patch_set":1,"id":"49f73b27_b0261bc8","in_reply_to":"aaf7aa39_658707c2","updated":"2021-04-27 19:17:53.000000000","message":"Ack","commit_id":"39ad506d874d4711015184f52585b4215c9b84cc"},{"author":{"_account_id":5976,"name":"Go Bot","email":"gobot@golang.org","tags":["SERVICE_USER"]},"tag":"autogenerated:trybots~failed","change_message_id":"d7e2ff4f58b281bc3120ad79a9c5bf7c87c0ec1b","unresolved":true,"patch_set":1,"id":"50a54b3c_f95f3567","in_reply_to":"c3e462db_b5e1efca","updated":"2021-04-27 07:22:38.000000000","message":"1 of 26 SlowBots failed.\nFailed on linux-arm64-aws: https://storage.googleapis.com/go-build-log/39ad506d/linux-arm64-aws_5dc1efb9.log\n\nConsult https://build.golang.org/ to see whether they are new failures. Keep in mind that TryBots currently test *exactly* your git commit, without rebasing. If your commit\u0027s git parent is old, the failure might\u0027ve already been fixed.\n\nSlowBot builds that ran:\n* linux-amd64-longtest\n","commit_id":"39ad506d874d4711015184f52585b4215c9b84cc"},{"author":{"_account_id":5976,"name":"Go Bot","email":"gobot@golang.org","tags":["SERVICE_USER"]},"tag":"autogenerated:trybots~happy","change_message_id":"9ed90c9e9b43e3c2c4d2b6ab8e8a121686d5da88","unresolved":false,"patch_set":1,"id":"13677404_911c1149","in_reply_to":"c3e462db_b5e1efca","updated":"2021-04-27 18:46:54.000000000","message":"SlowBots are happy.\n\nSlowBot builds that ran:\n* linux-amd64-longtest\n","commit_id":"39ad506d874d4711015184f52585b4215c9b84cc"},{"author":{"_account_id":5976,"name":"Go Bot","email":"gobot@golang.org","tags":["SERVICE_USER"]},"tag":"autogenerated:trybots~progress","change_message_id":"ab963b29aa95907e30e9f6a51ef1f080807bc8ab","unresolved":true,"patch_set":1,"id":"c3e462db_b5e1efca","in_reply_to":"cb0c9011_d26d6550","updated":"2021-04-27 07:18:11.000000000","message":"Build is still in progress... Status page: https://farmer.golang.org/try?commit\u003d39ad506d\nFailed on linux-arm64-aws: https://storage.googleapis.com/go-build-log/39ad506d/linux-arm64-aws_5dc1efb9.log\nOther builds still in progress; subsequent failure notices suppressed until final report.\n\nConsult https://build.golang.org/ to see whether they are new failures. Keep in mind that TryBots currently test *exactly* your git commit, without rebasing. If your commit\u0027s git parent is old, the failure might\u0027ve already been fixed.\n","commit_id":"39ad506d874d4711015184f52585b4215c9b84cc"}]}
+`)
+
+func TestListPatchSetThreads(t *testing.T) {
+	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json; charset=UTF-8")
+		w.WriteHeader(200)
+		w.Write(listPatchSetThreadsResponse)
+	}))
+	defer s.Close()
+	gerritClient := gerrit.NewClient(s.URL, gerrit.NoAuth)
+	threads, err := listPatchSetThreads(gerritClient, "go~master~I92400996cb051ab30e99bfffafd91ff32a1e7087")
+	if err != nil {
+		t.Fatal(err)
+	}
+	var mostRecentTryBotThread string
+	for _, tr := range threads {
+		if tr.unresolved {
+			t.Errorf("thread %s is unresolved", tr.root.ID)
+		}
+		if tr.root.Tag == tryBotsTag("beginning") {
+			mostRecentTryBotThread = tr.root.ID
+		}
+		if tr.root != tr.thread[0] {
+			t.Errorf("the root is not the first comment in thread")
+		}
+	}
+	if mostRecentTryBotThread != "aaf7aa39_658707c2" {
+		t.Errorf("wrong most recent TryBot thread: got %s, want %s", mostRecentTryBotThread, "aaf7aa39_658707c2")
+	}
+}
diff --git a/gerrit/gerrit.go b/gerrit/gerrit.go
index d4d1fe6..4f41796 100644
--- a/gerrit/gerrit.go
+++ b/gerrit/gerrit.go
@@ -503,12 +503,15 @@
 // CommentInfo contains information about an inline comment.
 // See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info.
 type CommentInfo struct {
-	PatchSet int          `json:"patch_set,omitempty"`
-	ID       string       `json:"id"`
-	Path     string       `json:"path,omitempty"`
-	Message  string       `json:"message,omitempty"`
-	Updated  TimeStamp    `json:"updated"`
-	Author   *AccountInfo `json:"author,omitempty"`
+	PatchSet   int          `json:"patch_set,omitempty"`
+	ID         string       `json:"id"`
+	Path       string       `json:"path,omitempty"`
+	Message    string       `json:"message,omitempty"`
+	Updated    TimeStamp    `json:"updated"`
+	Author     *AccountInfo `json:"author,omitempty"`
+	InReplyTo  string       `json:"in_reply_to,omitempty"`
+	Unresolved *bool        `json:"unresolved,omitempty"`
+	Tag        string       `json:"tag,omitempty"`
 }
 
 // ListFiles retrieves a map of filenames to FileInfo's for the given change ID and revision.