[gold] Use comment.Store in generating search results.

Bug: skia:6630
Change-Id: Ie13c240d94c586843c59815dea36d2b76263df40
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/271605
Reviewed-by: Leandro Lovisolo <lovisolo@google.com>
diff --git a/golden/cmd/skiacorrectness/main.go b/golden/cmd/skiacorrectness/main.go
index e622a34..1b5dcae 100644
--- a/golden/cmd/skiacorrectness/main.go
+++ b/golden/cmd/skiacorrectness/main.go
@@ -424,7 +424,8 @@
 	}
 	sklog.Infof("Indexer created.")
 
-	searchAPI := search.New(diffStore, expStore, ixr, cls, tjs, publiclyViewableParams)
+	// TODO(kjlubick) include non-nil comment.Store when it is implemented.
+	searchAPI := search.New(diffStore, expStore, ixr, cls, tjs, nil, publiclyViewableParams)
 
 	sklog.Infof("Search API created")
 
diff --git a/golden/go/search/frontend/types.go b/golden/go/search/frontend/types.go
index ce5d004..ea6590d 100644
--- a/golden/go/search/frontend/types.go
+++ b/golden/go/search/frontend/types.go
@@ -3,8 +3,11 @@
 package frontend
 
 import (
+	"time"
+
 	"go.skia.org/infra/go/paramtools"
 	"go.skia.org/infra/go/tiling"
+	"go.skia.org/infra/golden/go/comment/trace"
 	"go.skia.org/infra/golden/go/diff"
 	"go.skia.org/infra/golden/go/search/common"
 	"go.skia.org/infra/golden/go/types"
@@ -14,10 +17,11 @@
 // Search(...) function of SearchAPI and intended to be
 // returned as JSON in an HTTP response.
 type SearchResponse struct {
-	Digests []*SRDigest      `json:"digests"`
-	Offset  int              `json:"offset"`
-	Size    int              `json:"size"`
-	Commits []*tiling.Commit `json:"commits"`
+	Digests       []*SRDigest      `json:"digests"`
+	Offset        int              `json:"offset"`
+	Size          int              `json:"size"`
+	Commits       []*tiling.Commit `json:"commits"`
+	TraceComments []TraceComment   `json:"trace_comments"`
 }
 
 // SRDigest is a single search result digest returned
@@ -45,8 +49,9 @@
 
 // DigestDetails contains details about a digest.
 type DigestDetails struct {
-	Digest  *SRDigest        `json:"digest"`
-	Commits []*tiling.Commit `json:"commits"`
+	Digest        *SRDigest        `json:"digest"`
+	Commits       []*tiling.Commit `json:"commits"`
+	TraceComments []TraceComment   `json:"trace_comments"`
 }
 
 // Point is a single point. Used to draw the trace diagrams on the frontend.
@@ -59,13 +64,50 @@
 // Trace describes a single trace, used in TraceGroup.
 // TODO(kjlubick) Having many traces yields a large amount of JSON, which can take ~hundreds of
 //   milliseconds to gzip. We can probably remove the X and Y from Point (as they are somewhat
-//   redundant with the the index of the point and the index of the trace respectively).
-//   Additionally, we might be able to "compress" Params into an OrderedParamsSet and have
-//   an array of ints here instead of the whole map.
+//   redundant with the the index of the point and the index of the trace respectively). A quick
+//   grep over a JSON response from production shows that the Points take up over 2/3 of the size
+//   of the response (each point takes up ~20 bytes and when the tile size is 200, that means each
+//   trace adds 4k of code size). Additionally, we might be able to "compress" Params into an
+//   OrderedParamsSet and have an array of ints here instead of the whole map.
 type Trace struct {
-	Data   []Point           `json:"data"`  // One Point for each test result.
-	ID     tiling.TraceID    `json:"label"` // The id of the trace. Keep the json as label to be compatible with dots-sk.
+	Data []Point `json:"data"` // One Point for each test result.
+	// The id of the trace. Keep the json as label to be compatible with dots-sk.
+	ID     tiling.TraceID    `json:"label"`
 	Params map[string]string `json:"params"`
+	// CommentIndices are indices into the TraceComments slice on the final result. For example,
+	// a 1 means the second TraceComment in the top level TraceComments applies to this trace.
+	CommentIndices []int `json:"comment_indicies"`
+}
+
+// TraceComment is the frontend representation of a trace.Comment
+type TraceComment struct {
+	ID string `json:"id"`
+	// CreatedBy is the email address of the user who created this trace comment.
+	CreatedBy string `json:"created_by"`
+	// UpdatedBy is the email address of the user who most recently updated this trace comment.
+	UpdatedBy string `json:"updated_by"`
+	// CreatedTS is when the comment was created.
+	CreatedTS time.Time `json:"created_ts"`
+	// UpdatedTS is when the comment was updated.
+	UpdatedTS time.Time `json:"updated_ts"`
+	// Text is an arbitrary string. There can be special rules that only the frontend cares about
+	// (e.g. some markdown or coordinates).
+	Text string `json:"text"`
+	// QueryToMatch represents which traces this trace comment should apply to.
+	QueryToMatch paramtools.ParamSet `json:"query"`
+}
+
+// ToTraceComment converts a trace.Comment into a TraceComment
+func ToTraceComment(c trace.Comment) TraceComment {
+	return TraceComment{
+		ID:           c.ID,
+		CreatedBy:    c.CreatedBy,
+		UpdatedBy:    c.UpdatedBy,
+		CreatedTS:    c.CreatedTS,
+		UpdatedTS:    c.UpdatedTS,
+		Text:         c.Comment,
+		QueryToMatch: c.QueryToMatch,
+	}
 }
 
 // DigestStatus is a digest and its status, used in TraceGroup.
diff --git a/golden/go/search/search.go b/golden/go/search/search.go
index 1e31a70..7fc3622 100644
--- a/golden/go/search/search.go
+++ b/golden/go/search/search.go
@@ -17,6 +17,7 @@
 	"go.skia.org/infra/go/util"
 	"go.skia.org/infra/golden/go/clstore"
 	"go.skia.org/infra/golden/go/code_review"
+	"go.skia.org/infra/golden/go/comment"
 	"go.skia.org/infra/golden/go/diff"
 	"go.skia.org/infra/golden/go/expstorage"
 	"go.skia.org/infra/golden/go/indexer"
@@ -56,6 +57,7 @@
 	indexSource       indexer.IndexSource
 	changeListStore   clstore.Store
 	tryJobStore       tjstore.Store
+	commentStore      comment.Store
 
 	// storeCache allows for better performance by caching values from changeListStore and
 	// tryJobStore for a little while, before evicting them.
@@ -68,13 +70,14 @@
 }
 
 // New returns a new SearchImpl instance.
-func New(ds diff.DiffStore, es expstorage.ExpectationsStore, is indexer.IndexSource, cls clstore.Store, tjs tjstore.Store, publiclyViewableParams paramtools.ParamSet) *SearchImpl {
+func New(ds diff.DiffStore, es expstorage.ExpectationsStore, is indexer.IndexSource, cls clstore.Store, tjs tjstore.Store, cs comment.Store, publiclyViewableParams paramtools.ParamSet) *SearchImpl {
 	return &SearchImpl{
 		diffStore:              ds,
 		expectationsStore:      es,
 		indexSource:            is,
 		changeListStore:        cls,
 		tryJobStore:            tjs,
+		commentStore:           cs,
 		publiclyViewableParams: publiclyViewableParams,
 
 		storeCache: ttlcache.New(searchCacheFreshness, searchCacheCleanup),
@@ -147,7 +150,7 @@
 	// bulk triage, but only the digests that are going to be shown are padded
 	// with additional information.
 	displayRet, offset := s.sortAndLimitDigests(ctx, q, ret, int(q.Offset), int(q.Limit))
-	s.addParamsAndTraces(ctx, displayRet, inter, exp, idx)
+	traceComments := s.addParamsTracesAndComments(ctx, displayRet, inter, exp, idx)
 
 	// Return all digests with the selected offset within the result set.
 	searchRet := &frontend.SearchResponse{
@@ -155,7 +158,8 @@
 		Offset:  offset,
 		Size:    len(displayRet),
 		// TODO(kjlubick) maybe omit Commits for ChangeList Queries.
-		Commits: idx.Tile().GetTile(types.ExcludeIgnoredTraces).Commits,
+		Commits:       idx.Tile().GetTile(types.ExcludeIgnoredTraces).Commits,
+		TraceComments: traceComments,
 	}
 	return searchRet, nil
 }
@@ -211,14 +215,16 @@
 		return nil, skerr.Wrapf(err, "Fetching reference diffs for test %s, digest %s", test, digest)
 	}
 
+	var traceComments []frontend.TraceComment
 	if hasTraces {
 		// Get the params and traces.
-		s.addParamsAndTraces(ctx, ret, inter, exp, idx)
+		traceComments = s.addParamsTracesAndComments(ctx, ret, inter, exp, idx)
 	}
 
 	return &frontend.DigestDetails{
-		Digest:  ret[0],
-		Commits: tile.Commits,
+		Digest:        ret[0],
+		Commits:       tile.Commits,
+		TraceComments: traceComments,
 	}, nil
 }
 
@@ -590,25 +596,43 @@
 	return digestInfo[offset:end], offset
 }
 
-// addParamsAndTraces adds information to the given result that is necessary
+// addParamsTracesAndComments adds information to the given result that is necessary
 // to draw them, i.e. the information what digest/image appears at what commit and
 // what were the union of parameters that generate the digest. This should be
 // only done for digests that are intended to be displayed.
-func (s *SearchImpl) addParamsAndTraces(ctx context.Context, digestInfo []*frontend.SRDigest, inter srInterMap, exp expectations.Classifier, idx indexer.IndexSearcher) {
+func (s *SearchImpl) addParamsTracesAndComments(ctx context.Context, digestInfo []*frontend.SRDigest, inter srInterMap, exp expectations.Classifier, idx indexer.IndexSearcher) []frontend.TraceComment {
 	tile := idx.Tile().GetTile(types.ExcludeIgnoredTraces)
 	last := tile.LastCommitIndex()
+	var traceComments []frontend.TraceComment
+	// TODO(kjlubick) remove this check once the commentStore is implemented and included from main.
+	if s.commentStore != nil {
+		xtc, err := s.commentStore.ListComments(ctx)
+		if err != nil {
+			sklog.Warningf("Omitting comments due to error: %s", err)
+			traceComments = nil
+		} else {
+			for _, tc := range xtc {
+				traceComments = append(traceComments, frontend.ToTraceComment(tc))
+			}
+			sort.Slice(traceComments, func(i, j int) bool {
+				return traceComments[i].UpdatedTS.Before(traceComments[j].UpdatedTS)
+			})
+		}
+	}
+
 	for _, di := range digestInfo {
 		// Add the parameters and the drawable traces to the result.
 		di.ParamSet = inter[di.Test][di.Digest].params
 		di.ParamSet.Normalize()
-		di.Traces = s.getDrawableTraces(di.Test, di.Digest, last, exp, inter[di.Test][di.Digest].traces)
+		di.Traces = s.getDrawableTraces(di.Test, di.Digest, last, exp, inter[di.Test][di.Digest].traces, traceComments)
 		di.Traces.TileSize = len(tile.Commits)
 	}
+	return traceComments
 }
 
 // getDrawableTraces returns an instance of TraceGroup which allows us
 // to draw the traces for the given test/digest.
-func (s *SearchImpl) getDrawableTraces(test types.TestName, digest types.Digest, last int, exp expectations.Classifier, traces map[tiling.TraceID]*types.GoldenTrace) *frontend.TraceGroup {
+func (s *SearchImpl) getDrawableTraces(test types.TestName, digest types.Digest, last int, exp expectations.Classifier, traces map[tiling.TraceID]*types.GoldenTrace, comments []frontend.TraceComment) *frontend.TraceGroup {
 	// Get the information necessary to draw the traces.
 	traceIDs := make([]tiling.TraceID, 0, len(traces))
 	for traceID := range traces {
@@ -668,6 +692,12 @@
 		}
 		// Trim the leading traces if necessary.
 		tr.Data = tr.Data[insertNext+1:]
+
+		for i, c := range comments {
+			if c.QueryToMatch.MatchesParams(tr.Params) {
+				tr.CommentIndices = append(tr.CommentIndices, i)
+			}
+		}
 	}
 
 	return &frontend.TraceGroup{
diff --git a/golden/go/search/search_test.go b/golden/go/search/search_test.go
index b3920de..dc9bab0 100644
--- a/golden/go/search/search_test.go
+++ b/golden/go/search/search_test.go
@@ -4,15 +4,21 @@
 	"context"
 	"errors"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/require"
 
 	"go.skia.org/infra/go/paramtools"
 	"go.skia.org/infra/go/testutils"
 	"go.skia.org/infra/go/testutils/unittest"
+	"go.skia.org/infra/go/tiling"
 	mock_clstore "go.skia.org/infra/golden/go/clstore/mocks"
 	"go.skia.org/infra/golden/go/code_review"
+	"go.skia.org/infra/golden/go/comment"
+	mock_comment "go.skia.org/infra/golden/go/comment/mocks"
+	"go.skia.org/infra/golden/go/comment/trace"
 	"go.skia.org/infra/golden/go/diff"
 	mock_diffstore "go.skia.org/infra/golden/go/diffstore/mocks"
 	"go.skia.org/infra/golden/go/digest_counter"
@@ -52,7 +58,7 @@
 	addDiffData(mds, data.BetaUntriaged1Digest, data.BetaGood1Digest, makeBigDiffMetric())
 	// BetaUntriaged1Digest has no negative images to compare against.
 
-	s := New(mds, makeThreeDevicesExpectationStore(), makeThreeDevicesIndexer(), nil, nil, everythingPublic)
+	s := New(mds, makeThreeDevicesExpectationStore(), makeThreeDevicesIndexer(), nil, nil, emptyCommentStore(), everythingPublic)
 
 	q := &query.Search{
 		ChangeListID: "",
@@ -206,8 +212,9 @@
 	addDiffData(mds, data.BetaUntriaged1Digest, data.BetaGood1Digest, makeBigDiffMetric())
 	// BetaUntriaged1Digest has no negative images to compare against.
 
-	s := New(mds, makeThreeDevicesExpectationStore(), makeThreeDevicesIndexer(), nil, nil, everythingPublic)
+	s := New(mds, makeThreeDevicesExpectationStore(), makeThreeDevicesIndexer(), nil, nil, emptyCommentStore(), everythingPublic)
 
+	// spotCheck is the subset of data we assert against.
 	type spotCheck struct {
 		test            types.TestName
 		digest          types.Digest
@@ -488,6 +495,114 @@
 	}, []spotCheck{})
 }
 
+// TestSearch_ThreeDevicesCorpusWithComments_CommentsInResults ensures that search results contain
+// comments when it matches the traces.
+func TestSearch_ThreeDevicesCorpusWithComments_CommentsInResults(t *testing.T) {
+	unittest.SmallTest(t)
+
+	bullheadComment := trace.Comment{
+		ID:        "1",
+		CreatedBy: "zulu@example.com",
+		UpdatedBy: "zulu@example.com",
+		CreatedTS: time.Date(2020, time.February, 19, 18, 17, 16, 0, time.UTC),
+		UpdatedTS: time.Date(2020, time.February, 19, 18, 17, 16, 0, time.UTC),
+		Comment:   "All bullhead devices draw upside down",
+		QueryToMatch: paramtools.ParamSet{
+			"device": []string{data.BullheadDevice},
+		},
+	}
+
+	alphaTestComment := trace.Comment{
+		ID:        "2",
+		CreatedBy: "yankee@example.com",
+		UpdatedBy: "xray@example.com",
+		CreatedTS: time.Date(2020, time.February, 2, 18, 17, 16, 0, time.UTC),
+		UpdatedTS: time.Date(2020, time.February, 20, 18, 17, 16, 0, time.UTC),
+		Comment:   "Watch pixel 0,4 to make sure it's not purple",
+		QueryToMatch: paramtools.ParamSet{
+			types.PRIMARY_KEY_FIELD: []string{string(data.AlphaTest)},
+		},
+	}
+
+	betaTestBullheadComment := trace.Comment{
+		ID:        "4",
+		CreatedBy: "victor@example.com",
+		UpdatedBy: "victor@example.com",
+		CreatedTS: time.Date(2020, time.February, 22, 18, 17, 16, 0, time.UTC),
+		UpdatedTS: time.Date(2020, time.February, 22, 18, 17, 16, 0, time.UTC),
+		Comment:   "Being upside down, this test should be ABGR instead of RGBA",
+		QueryToMatch: paramtools.ParamSet{
+			"device":                []string{data.BullheadDevice},
+			types.PRIMARY_KEY_FIELD: []string{string(data.BetaTest)},
+		},
+	}
+
+	commentAppliesToNothing := trace.Comment{
+		ID:        "3",
+		CreatedBy: "uniform@example.com",
+		UpdatedBy: "uniform@example.com",
+		CreatedTS: time.Date(2020, time.February, 26, 26, 26, 26, 0, time.UTC),
+		UpdatedTS: time.Date(2020, time.February, 26, 26, 26, 26, 0, time.UTC),
+		Comment:   "On Wednesdays, this device draws pink",
+		QueryToMatch: paramtools.ParamSet{
+			"device": []string{"This device does not exist"},
+		},
+	}
+
+	mcs := &mock_comment.Store{}
+	// Return these in an arbitrary, unsorted order
+	mcs.On("ListComments", testutils.AnyContext).Return([]trace.Comment{commentAppliesToNothing, alphaTestComment, betaTestBullheadComment, bullheadComment}, nil)
+
+	s := New(makeStubDiffStore(), makeThreeDevicesExpectationStore(), makeThreeDevicesIndexer(), nil, nil, mcs, everythingPublic)
+
+	q := &query.Search{
+		// Set all to true so all 6 traces show up in the final results.
+		Unt:  true,
+		Pos:  true,
+		Neg:  true,
+		Head: true,
+
+		Metric:   diff.CombinedMetric,
+		FRGBAMin: 0,
+		FRGBAMax: 255,
+		FDiffMax: -1,
+		Sort:     query.SortAscending,
+	}
+
+	resp, err := s.Search(context.Background(), q)
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	// There are 4 unique digests at HEAD on the three_devices corpus. Do a quick smoke test to make
+	// sure we have one search result for each of them.
+	require.Len(t, resp.Digests, 4)
+	f := frontend.ToTraceComment
+	// This should be sorted by UpdatedTS.
+	assert.Equal(t, []frontend.TraceComment{
+		f(bullheadComment), f(alphaTestComment), f(betaTestBullheadComment), f(commentAppliesToNothing),
+	}, resp.TraceComments)
+
+	// These numbers are indices into the resp.TraceComments. The nil entries are expected to have
+	// no comments that match them.
+	expectedComments := map[tiling.TraceID][]int{
+		data.AnglerAlphaTraceID:     {1},
+		data.AnglerBetaTraceID:      nil,
+		data.BullheadAlphaTraceID:   {0, 1},
+		data.BullheadBetaTraceID:    {0, 2},
+		data.CrosshatchAlphaTraceID: {1},
+		data.CrosshatchBetaTraceID:  nil,
+	}
+	// We only check that the traces have their associated comments. We rely on the other tests
+	// to make sure the other fields are correct.
+	traceCount := 0
+	for _, r := range resp.Digests {
+		for _, tr := range r.Traces.Traces {
+			traceCount++
+			assert.Equal(t, expectedComments[tr.ID], tr.CommentIndices, "trace id %q under digest", tr.ID, r.Digest)
+		}
+	}
+	assert.Equal(t, 6, traceCount, "Not all traces were in the final result")
+}
+
 // TestSearchThreeDevicesChangeListSunnyDay covers the case
 // where two tryjobs have been run on a given CL and PS, one on the
 // angler bot and one on the bullhead bot. The master branch
@@ -595,7 +710,7 @@
 	mds := makeDiffStoreWithNoFailures()
 	addDiffData(mds, BetaBrandNewDigest, data.BetaGood1Digest, makeSmallDiffMetric())
 
-	s := New(mds, mes, makeThreeDevicesIndexer(), mcls, mtjs, everythingPublic)
+	s := New(mds, mes, makeThreeDevicesIndexer(), mcls, mtjs, nil, everythingPublic)
 
 	q := &query.Search{
 		ChangeListID:  clID,
@@ -682,7 +797,7 @@
 	addDiffData(mds, digestWeWantDetailsAbout, data.AlphaGood1Digest, nil)
 	addDiffData(mds, digestWeWantDetailsAbout, data.AlphaBad1Digest, makeBigDiffMetric())
 
-	s := New(mds, makeThreeDevicesExpectationStore(), makeThreeDevicesIndexer(), nil, nil, everythingPublic)
+	s := New(mds, makeThreeDevicesExpectationStore(), makeThreeDevicesIndexer(), nil, nil, emptyCommentStore(), everythingPublic)
 
 	details, err := s.GetDigestDetails(context.Background(), testWeWantDetailsAbout, digestWeWantDetailsAbout, "", "")
 	require.NoError(t, err)
@@ -779,7 +894,7 @@
 			data.AlphaBad1Digest: makeBigDiffMetric(),
 		}, nil)
 
-	s := New(mds, mes, makeThreeDevicesIndexer(), nil, nil, everythingPublic)
+	s := New(mds, mes, makeThreeDevicesIndexer(), nil, nil, emptyCommentStore(), everythingPublic)
 
 	details, err := s.GetDigestDetails(context.Background(), testWeWantDetailsAbout, digestWeWantDetailsAbout, testCLID, testCRS)
 	require.NoError(t, err)
@@ -797,7 +912,7 @@
 	mds := makeDiffStoreWithNoFailures()
 	addDiffData(mds, digestWeWantDetailsAbout, data.BetaGood1Digest, makeSmallDiffMetric())
 
-	s := New(mds, makeThreeDevicesExpectationStore(), makeThreeDevicesIndexer(), nil, nil, everythingPublic)
+	s := New(mds, makeThreeDevicesExpectationStore(), makeThreeDevicesIndexer(), nil, nil, nil, everythingPublic)
 
 	d, err := s.GetDigestDetails(context.Background(), testWeWantDetailsAbout, digestWeWantDetailsAbout, "", "")
 	require.NoError(t, err)
@@ -835,7 +950,7 @@
 	mds := makeDiffStoreWithNoFailures()
 	mds.On("Get", testutils.AnyContext, digestWeWantDetailsAbout, types.DigestSlice{data.BetaGood1Digest}).Return(nil, errors.New("invalid digest"))
 
-	s := New(mds, makeThreeDevicesExpectationStore(), makeThreeDevicesIndexer(), nil, nil, everythingPublic)
+	s := New(mds, makeThreeDevicesExpectationStore(), makeThreeDevicesIndexer(), nil, nil, nil, everythingPublic)
 
 	r, err := s.GetDigestDetails(context.Background(), testWeWantDetailsAbout, digestWeWantDetailsAbout, "", "")
 	require.NoError(t, err)
@@ -850,7 +965,7 @@
 	const digestWeWantDetailsAbout = data.AlphaGood1Digest
 	const testWeWantDetailsAbout = types.TestName("invalid test")
 
-	s := New(nil, nil, makeThreeDevicesIndexer(), nil, nil, everythingPublic)
+	s := New(nil, nil, makeThreeDevicesIndexer(), nil, nil, nil, everythingPublic)
 
 	_, err := s.GetDigestDetails(context.Background(), testWeWantDetailsAbout, digestWeWantDetailsAbout, "", "")
 	require.Error(t, err)
@@ -863,7 +978,7 @@
 	const digestWeWantDetailsAbout = types.Digest("invalid digest")
 	const testWeWantDetailsAbout = types.TestName("invalid test")
 
-	s := New(nil, nil, makeThreeDevicesIndexer(), nil, nil, everythingPublic)
+	s := New(nil, nil, makeThreeDevicesIndexer(), nil, nil, nil, everythingPublic)
 
 	_, err := s.GetDigestDetails(context.Background(), testWeWantDetailsAbout, digestWeWantDetailsAbout, "", "")
 	require.Error(t, err)
@@ -880,7 +995,7 @@
 	mds := makeDiffStoreWithNoFailures()
 	addDiffData(mds, leftDigest, rightDigest, makeSmallDiffMetric())
 
-	s := New(mds, makeThreeDevicesExpectationStore(), makeThreeDevicesIndexer(), nil, nil, everythingPublic)
+	s := New(mds, makeThreeDevicesExpectationStore(), makeThreeDevicesIndexer(), nil, nil, nil, everythingPublic)
 
 	cd, err := s.DiffDigests(context.Background(), testWeWantDetailsAbout, leftDigest, rightDigest, "", "")
 	require.NoError(t, err)
@@ -925,7 +1040,7 @@
 	mds := makeDiffStoreWithNoFailures()
 	addDiffData(mds, leftDigest, rightDigest, makeSmallDiffMetric())
 
-	s := New(mds, mes, makeThreeDevicesIndexer(), nil, nil, everythingPublic)
+	s := New(mds, mes, makeThreeDevicesIndexer(), nil, nil, nil, everythingPublic)
 
 	cd, err := s.DiffDigests(context.Background(), testWeWantDetailsAbout, leftDigest, rightDigest, clID, crs)
 	require.NoError(t, err)
@@ -1049,7 +1164,7 @@
 		},
 	}, nil).Once()
 
-	s := New(nil, mes, mi, nil, mtjs, everythingPublic)
+	s := New(nil, mes, mi, nil, mtjs, nil, everythingPublic)
 
 	dl, err := s.UntriagedUnignoredTryJobExclusiveDigests(context.Background(), expectedID)
 	require.NoError(t, err)
@@ -1140,3 +1255,23 @@
 		},
 	}
 }
+
+func emptyCommentStore() comment.Store {
+	mcs := &mock_comment.Store{}
+	mcs.On("ListComments", testutils.AnyContext).Return(nil, nil)
+	return mcs
+}
+
+// makeStubDiffStore returns a diffstore that returns the small diff metric for every call to Get.
+func makeStubDiffStore() *mock_diffstore.DiffStore {
+	mds := &mock_diffstore.DiffStore{}
+	mds.On("UnavailableDigests", testutils.AnyContext).Return(map[types.Digest]*diff.DigestFailure{}, nil)
+	mds.On("Get", testutils.AnyContext, mock.Anything, mock.Anything).Return(func(_ context.Context, _ types.Digest, rights types.DigestSlice) map[types.Digest]*diff.DiffMetrics {
+		rv := make(map[types.Digest]*diff.DiffMetrics, len(rights))
+		for _, right := range rights {
+			rv[right] = makeSmallDiffMetric()
+		}
+		return rv
+	}, nil)
+	return mds
+}