maintner: fix GerritMeta.Hashtags to look at earlier meta parents for answer

The Gerrit meta commit graph is a linear history. The most recent meta
with a "Hashtags: " footer line has the complete set. We just have to
go back and look for it.

Fixes golang/go#28318
Updates golang/go#28510 (fixes after gopherbot re-deployed)
Updates golang/go#28320 (fixes after gopherbot re-deployed)

Change-Id: I43705075800ae3d353c1c8f60ab7685883ea5602
Reviewed-on: https://go-review.googlesource.com/c/152779
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/maintner/gerrit.go b/maintner/gerrit.go
index 88d03f0..ffd03ca 100644
--- a/maintner/gerrit.go
+++ b/maintner/gerrit.go
@@ -364,7 +364,7 @@
 func (cl *GerritCL) updateBranch() {
 	for i := len(cl.Metas) - 1; i >= 0; i-- {
 		mc := cl.Metas[i]
-		branch, _ := lineValue(mc.Commit.Msg, "Branch:")
+		branch := lineValue(mc.Commit.Msg, "Branch:")
 		if branch != "" {
 			cl.branch = strings.TrimPrefix(branch, "refs/heads/")
 			return
@@ -372,19 +372,21 @@
 	}
 }
 
-// lineValue extracts a value from an RFC 822-style "key: value" series of lines.
+// lineValueOK extracts a value from an RFC 822-style "key: value" series of lines.
 // If all is,
 //    foo: bar
 //    bar: baz
 // lineValue(all, "foo:") returns "bar". It trims any whitespace.
 // The prefix is case sensitive and must include the colon.
-func lineValue(all, prefix string) (value, rest string) {
+// The ok value reports whether a line with such a prefix is found, even if its
+// value is empty. If ok is true, the rest value contains the subsequent lines.
+func lineValueOK(all, prefix string) (value, rest string, ok bool) {
 	orig := all
 	consumed := 0
 	for {
 		i := strings.Index(all, prefix)
 		if i == -1 {
-			return "", ""
+			return "", "", false
 		}
 		if i > 0 && all[i-1] != '\n' && all[i-1] != '\r' {
 			all = all[i+len(prefix):]
@@ -399,17 +401,26 @@
 		} else {
 			consumed = len(orig)
 		}
-		return strings.TrimSpace(val), orig[consumed:]
+		return strings.TrimSpace(val), orig[consumed:], true
 	}
 }
 
+func lineValue(all, prefix string) string {
+	value, _, _ := lineValueOK(all, prefix)
+	return value
+}
+
+func lineValueRest(all, prefix string) (value, rest string) {
+	value, rest, _ = lineValueOK(all, prefix)
+	return
+}
+
 // WorkInProgress reports whether the CL has its Work-in-progress bit set, per
 // https://gerrit-review.googlesource.com/Documentation/intro-user.html#wip
 func (cl *GerritCL) WorkInProgress() bool {
 	var wip bool
 	for _, m := range cl.Metas {
-		v, _ := lineValue(m.Commit.Msg, "Work-in-progress:")
-		switch v {
+		switch lineValue(m.Commit.Msg, "Work-in-progress:") {
 		case "true":
 			wip = true
 		case "false":
@@ -438,8 +449,7 @@
 		panic("Footer key does not end in colon")
 	}
 	// TODO: git footers are treated as multimaps. Account for this.
-	v, _ := lineValue(cl.Commit.Msg, key)
-	return v
+	return lineValue(cl.Commit.Msg, key)
 }
 
 // OwnerID returns the ID of the CL’s owner. It will return -1 on error.
@@ -1292,7 +1302,6 @@
 type GerritMeta struct {
 	// Commit points up to the git commit for this Gerrit NoteDB meta commit.
 	Commit *GitCommit
-
 	// CL is the Gerrit CL this metadata is for.
 	CL *GerritCL
 
@@ -1324,17 +1333,39 @@
 	return m.Commit.Msg[i+2:]
 }
 
-// Hashtags returns the current set of hashtags.
+// Hashtags returns the set of hashtags on m's CL as of the time of m.
 func (m *GerritMeta) Hashtags() GerritHashtags {
-	tags, _ := lineValue(m.Footer(), "Hashtags: ")
-	return GerritHashtags(tags)
+	// If this GerritMeta set hashtags, use it.
+	tags, _, ok := lineValueOK(m.Footer(), "Hashtags: ")
+	if ok {
+		return GerritHashtags(tags)
+	}
+
+	// Otherwise, look at older metas (from most recent to oldest)
+	// to find most recent value. Ignore anything that's newer
+	// than m.
+	sawThisMeta := false // whether we've seen 'm'
+	metas := m.CL.Metas
+	for i := len(metas) - 1; i >= 0; i-- {
+		mp := metas[i]
+		if mp.Commit.Hash == m.Commit.Hash {
+			sawThisMeta = true
+			continue
+		}
+		if !sawThisMeta {
+			continue
+		}
+		if tags, _, ok := lineValueOK(mp.Footer(), "Hashtags: "); ok {
+			return GerritHashtags(tags)
+		}
+	}
+	return ""
 }
 
 // ActionTag returns the Gerrit "Tag" value from the meta commit.
 // These are of the form "autogenerated:gerrit:setHashtag".
 func (m *GerritMeta) ActionTag() string {
-	v, _ := lineValue(m.Footer(), "Tag: ")
-	return v
+	return lineValue(m.Footer(), "Tag: ")
 }
 
 // HashtagEdits returns the hashtags added and removed by this meta commit,
@@ -1354,7 +1385,7 @@
 	// Hashtag added: bar
 	// Hashtags added: foo, bar
 	for len(msg) > 0 {
-		value, rest := lineValue(msg, "Hash")
+		value, rest := lineValueRest(msg, "Hash")
 		msg = rest
 		colon := strings.IndexByte(value, ':')
 		if colon != -1 {
@@ -1419,8 +1450,7 @@
 		isNew := strings.Contains(footer, "\nTag: autogenerated:gerrit:newPatchSet\n")
 		email := mc.Commit.Author.Email()
 		if isNew {
-			commit, _ := lineValue(footer, "Commit: ")
-			if commit != "" {
+			if commit := lineValue(footer, "Commit: "); commit != "" {
 				// TODO: implement Gerrit's vote copying. For example,
 				// label.Label-Name.copyAllScoresIfNoChange defaults to true (as it is with Go's server)
 				// https://gerrit-review.googlesource.com/Documentation/config-labels.html#label_copyAllScoresIfNoChange
@@ -1447,7 +1477,7 @@
 		remain := footer
 		for len(remain) > 0 {
 			var labelEqVal string
-			labelEqVal, remain = lineValue(remain, "Label: ")
+			labelEqVal, remain = lineValueRest(remain, "Label: ")
 			if labelEqVal != "" {
 				label, value, whose := parseGerritLabelValue(labelEqVal)
 				if label != "" {
diff --git a/maintner/git.go b/maintner/git.go
index 1e65249..3d67010 100644
--- a/maintner/git.go
+++ b/maintner/git.go
@@ -364,7 +364,7 @@
 	// Patch-set: 1
 	// Reviewer: Ian Lance Taylor <5206@62eb7196-b449-3ce5-99f1-c037f21e1705>
 	// Label: Code-Review=+2
-	if reviewer, _ := lineValue(c.strb(msg), "Reviewer: "); reviewer != "" {
+	if reviewer := lineValue(c.strb(msg), "Reviewer: "); reviewer != "" {
 		gc.Reviewer = &GitPerson{Str: reviewer}
 	}
 
diff --git a/maintner/godata/godata_test.go b/maintner/godata/godata_test.go
index 2152338..edd18b0 100644
--- a/maintner/godata/godata_test.go
+++ b/maintner/godata/godata_test.go
@@ -8,10 +8,14 @@
 	"bytes"
 	"context"
 	"fmt"
+	"os"
+	"sort"
 	"strings"
 	"sync"
 	"testing"
 
+	"cloud.google.com/go/compute/metadata"
+	"golang.org/x/build/gerrit"
 	"golang.org/x/build/maintner"
 )
 
@@ -272,3 +276,84 @@
 		t.Errorf("got:\n%s\n\nwant prefix:\n%s", got, want)
 	}
 }
+
+func getGerritAuth() (username string, password string, err error) {
+	var slurp string
+	if metadata.OnGCE() {
+		for _, key := range []string{"gopherbot-gerrit-token", "maintner-gerrit-token", "gobot-password"} {
+			slurp, err = metadata.ProjectAttributeValue(key)
+			if err != nil || slurp == "" {
+				continue
+			}
+			break
+		}
+	}
+	if slurp == "" {
+		slurp = os.Getenv("TEST_GERRIT_AUTH")
+	}
+	f := strings.SplitN(strings.TrimSpace(slurp), ":", 2)
+	if len(f) == 1 {
+		// assume the whole thing is the token
+		return "git-gobot.golang.org", f[0], nil
+	}
+	if len(f) != 2 || f[0] == "" || f[1] == "" {
+		return "", "", fmt.Errorf("Expected Gerrit token %q to be of form <git-email>:<token>", slurp)
+	}
+	return f[0], f[1], nil
+}
+
+// Hit the Gerrit API and compare its computation of CLs' hashtags against what maintner thinks.
+// Off by default unless $TEST_GERRIT_AUTH is defined with "user:token", or we're running in the
+// prod project.
+func TestGerritHashtags(t *testing.T) {
+	if testing.Short() {
+		t.Skip("skipping in short mode")
+	}
+	c := getGoData(t)
+	user, pass, err := getGerritAuth()
+	if err != nil {
+		t.Skipf("no Gerrit auth defined, skipping: %v", err)
+	}
+	gc := gerrit.NewClient("https://go-review.googlesource.com", gerrit.BasicAuth(user, pass))
+	ctx := context.Background()
+	more := true
+	n := 0
+	for more {
+		// We search Gerrit for "hashtag", which seems to also
+		// search auto-generated gerrit meta (notedb) texts,
+		// so this has the effect of searching for all Gerrit
+		// changes that have ever had hashtags added or
+		// removed:
+		cis, err := gc.QueryChanges(ctx, "hashtag", gerrit.QueryChangesOpt{
+			Start: n,
+		})
+		if err != nil {
+			t.Fatal(err)
+		}
+		for _, ci := range cis {
+			n++
+			cl := c.Gerrit().Project("go.googlesource.com", ci.Project).CL(int32(ci.ChangeNumber))
+			if cl == nil {
+				t.Logf("Ignoring not-in-maintner %s/%v", ci.Project, ci.ChangeNumber)
+				continue
+			}
+			sort.Strings(ci.Hashtags)
+			want := strings.Join(ci.Hashtags, ", ")
+			got := canonicalTagList(string(cl.Meta.Hashtags()))
+			if got != want {
+				t.Errorf("ci: https://golang.org/cl/%d (%s) -- maintner = %q; want gerrit value %q", ci.ChangeNumber, ci.Project, got, want)
+			}
+			more = ci.MoreChanges
+		}
+	}
+	t.Logf("N = %v", n)
+}
+
+func canonicalTagList(s string) string {
+	var sl []string
+	for _, v := range strings.Split(s, ",") {
+		sl = append(sl, strings.TrimSpace(v))
+	}
+	sort.Strings(sl)
+	return strings.Join(sl, ", ")
+}