[gold] Add RPC to smartly redirect changelist searches.
This should greatly increase the chance of redirecting the user
to a page for which they see untriaged digests (i.e. the reason
they clicked on the link in the logs, code review comment, or email).
Bug: skia:9879
Change-Id: Ie08b7826c02085dea785e67812f8c76c4921daa7
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/290025
Reviewed-by: Leandro Lovisolo <lovisolo@google.com>
diff --git a/golden/cmd/skiacorrectness/main.go b/golden/cmd/skiacorrectness/main.go
index a6b7456..b3e6712 100644
--- a/golden/cmd/skiacorrectness/main.go
+++ b/golden/cmd/skiacorrectness/main.go
@@ -575,6 +575,7 @@
loggedRouter.HandleFunc("/diff", templateHandler("diff.html"))
loggedRouter.HandleFunc("/detail", templateHandler("details.html"))
loggedRouter.HandleFunc("/details", templateHandler("details.html"))
+ loggedRouter.HandleFunc("/cl/{system}/{id}", handlers.ChangeListSearchRedirect)
// This route handles the legacy polymer "single page" app model
loggedRouter.PathPrefix("/").Handler(templateHandler("index.html"))
diff --git a/golden/go/code_review/commenter/commenter_test.go b/golden/go/code_review/commenter/commenter_test.go
index 6b5067b..1bba1b5 100644
--- a/golden/go/code_review/commenter/commenter_test.go
+++ b/golden/go/code_review/commenter/commenter_test.go
@@ -242,7 +242,7 @@
mcr.On("System").Return("github")
// Pretend all CLs queried have 2 untriaged digests.
- msa.On("UntriagedUnignoredTryJobExclusiveDigests", testutils.AnyContext, mock.Anything).Return(&frontend.DigestList{
+ msa.On("UntriagedUnignoredTryJobExclusiveDigests", testutils.AnyContext, mock.Anything).Return(&frontend.UntriagedDigestList{
Digests: []types.Digest{"doesn't", "matter"},
TS: indexTime,
}, nil)
@@ -289,7 +289,7 @@
mcr.On("System").Return("github")
// Pretend all CLs queried have 2 untriaged digests.
- msa.On("UntriagedUnignoredTryJobExclusiveDigests", testutils.AnyContext, mock.Anything).Return(&frontend.DigestList{
+ msa.On("UntriagedUnignoredTryJobExclusiveDigests", testutils.AnyContext, mock.Anything).Return(&frontend.UntriagedDigestList{
Digests: nil,
TS: indexTime,
}, nil)
@@ -372,7 +372,7 @@
mcr.On("System").Return("github")
// Pretend all CLs queried have 2 untriaged digests.
- msa.On("UntriagedUnignoredTryJobExclusiveDigests", testutils.AnyContext, mock.Anything).Return(&frontend.DigestList{
+ msa.On("UntriagedUnignoredTryJobExclusiveDigests", testutils.AnyContext, mock.Anything).Return(&frontend.UntriagedDigestList{
Digests: []types.Digest{"doesn't", "matter"},
}, nil)
@@ -458,7 +458,7 @@
mcr.On("System").Return("gerritHub")
// Pretend all CLs queried have 2 untriaged digests.
- msa.On("UntriagedUnignoredTryJobExclusiveDigests", testutils.AnyContext, mock.Anything).Return(&frontend.DigestList{
+ msa.On("UntriagedUnignoredTryJobExclusiveDigests", testutils.AnyContext, mock.Anything).Return(&frontend.UntriagedDigestList{
Digests: []types.Digest{"doesn't", "matter"},
}, nil)
diff --git a/golden/go/search/frontend/types.go b/golden/go/search/frontend/types.go
index 57bf83e..75caa90 100644
--- a/golden/go/search/frontend/types.go
+++ b/golden/go/search/frontend/types.go
@@ -136,9 +136,14 @@
Right *SRDiffDigest `json:"right"` // The right hand digest, its params and the diff result.
}
-// DigestList represents multiple returned digests.
-type DigestList struct {
+// UntriagedDigestList represents multiple digests that are untriaged for a given query.
+type UntriagedDigestList struct {
Digests []types.Digest `json:"digests"`
+
+ // Corpora is filed with the strings representing a corpus that has one or more Digests belong
+ // to it. In other words, it summarizes where the Digests come from.
+ Corpora []string `json:"corpora"`
+
// TS is the time that this data was created. It might be served from a cache, so this time will
// not necessarily be "now".
TS time.Time `json:"ts"`
diff --git a/golden/go/search/mocks/SearchAPI.go b/golden/go/search/mocks/SearchAPI.go
index 85f17b8..63f5ca0 100644
--- a/golden/go/search/mocks/SearchAPI.go
+++ b/golden/go/search/mocks/SearchAPI.go
@@ -90,15 +90,15 @@
}
// UntriagedUnignoredTryJobExclusiveDigests provides a mock function with given fields: _a0, _a1
-func (_m *SearchAPI) UntriagedUnignoredTryJobExclusiveDigests(_a0 context.Context, _a1 tjstore.CombinedPSID) (*frontend.DigestList, error) {
+func (_m *SearchAPI) UntriagedUnignoredTryJobExclusiveDigests(_a0 context.Context, _a1 tjstore.CombinedPSID) (*frontend.UntriagedDigestList, error) {
ret := _m.Called(_a0, _a1)
- var r0 *frontend.DigestList
- if rf, ok := ret.Get(0).(func(context.Context, tjstore.CombinedPSID) *frontend.DigestList); ok {
+ var r0 *frontend.UntriagedDigestList
+ if rf, ok := ret.Get(0).(func(context.Context, tjstore.CombinedPSID) *frontend.UntriagedDigestList); ok {
r0 = rf(_a0, _a1)
} else {
if ret.Get(0) != nil {
- r0 = ret.Get(0).(*frontend.DigestList)
+ r0 = ret.Get(0).(*frontend.UntriagedDigestList)
}
}
diff --git a/golden/go/search/search.go b/golden/go/search/search.go
index 20a43315..a699d1d 100644
--- a/golden/go/search/search.go
+++ b/golden/go/search/search.go
@@ -819,7 +819,7 @@
// UntriagedUnignoredTryJobExclusiveDigests implements the SearchAPI interface. It uses the cached
// TryJobResults, so as to improve performance.
-func (s *SearchImpl) UntriagedUnignoredTryJobExclusiveDigests(ctx context.Context, psID tjstore.CombinedPSID) (*frontend.DigestList, error) {
+func (s *SearchImpl) UntriagedUnignoredTryJobExclusiveDigests(ctx context.Context, psID tjstore.CombinedPSID) (*frontend.UntriagedDigestList, error) {
var resultsForThisPS []tjstore.TryJobResult
listTS := time.Now()
clIdx := s.indexSource.GetIndexForCL(psID.CRS, psID.CL)
@@ -845,6 +845,7 @@
knownDigestsForTest := idx.DigestCountsByTest(types.IncludeIgnoredTraces)
var returnDigests []types.Digest
+ var returnCorpora []string
for _, tr := range resultsForThisPS {
if err := ctx.Err(); err != nil {
@@ -867,14 +868,18 @@
// This trace matches an ignore
continue
}
+ if corpus := p[types.CorpusField]; !util.In(corpus, returnCorpora) {
+ returnCorpora = append(returnCorpora, corpus)
+ }
returnDigests = append(returnDigests, tr.Digest)
}
// Sort digests alphabetically for determinism.
sort.Slice(returnDigests, func(i, j int) bool {
return returnDigests[i] < returnDigests[j]
})
- return &frontend.DigestList{
+ return &frontend.UntriagedDigestList{
Digests: returnDigests,
+ Corpora: returnCorpora,
TS: listTS,
}, nil
}
diff --git a/golden/go/search/search_test.go b/golden/go/search/search_test.go
index 0000b8c..3f8bd31 100644
--- a/golden/go/search/search_test.go
+++ b/golden/go/search/search_test.go
@@ -1476,6 +1476,7 @@
dl, err := s.UntriagedUnignoredTryJobExclusiveDigests(context.Background(), expectedID)
require.NoError(t, err)
+ assert.Equal(t, []string{"gm"}, dl.Corpora)
assert.Equal(t, []types.Digest{alphaUntriagedTryJobDigest, betaUntriagedTryJobDigest}, dl.Digests)
// TS should be very recent, since the results were freshly computed.
assert.True(t, dl.TS.After(time.Now().Add(-time.Minute)))
@@ -1594,8 +1595,11 @@
dl, err := s.UntriagedUnignoredTryJobExclusiveDigests(context.Background(), expectedID)
require.NoError(t, err)
- assert.Equal(t, []types.Digest{alphaUntriagedTryJobDigest, betaUntriagedTryJobDigest}, dl.Digests)
- assert.Equal(t, indexTS, dl.TS)
+ assert.Equal(t, &frontend.UntriagedDigestList{
+ Digests: []types.Digest{alphaUntriagedTryJobDigest, betaUntriagedTryJobDigest},
+ Corpora: []string{"gm"},
+ TS: indexTS,
+ }, dl)
}
// TestGetDrawableTraces_DigestIndicesAreCorrect tests that we generate the output required to draw
diff --git a/golden/go/search/types.go b/golden/go/search/types.go
index bcf5219..9a3dee2 100644
--- a/golden/go/search/types.go
+++ b/golden/go/search/types.go
@@ -30,5 +30,5 @@
// UntriagedUnignoredTryJobExclusiveDigests returns a list of untriaged, non-ignored,
// digests that are exclusive to a given CL (i.e. they don't also show up on the master branch
// in the last N commits).
- UntriagedUnignoredTryJobExclusiveDigests(context.Context, tjstore.CombinedPSID) (*frontend.DigestList, error)
+ UntriagedUnignoredTryJobExclusiveDigests(context.Context, tjstore.CombinedPSID) (*frontend.UntriagedDigestList, error)
}
diff --git a/golden/go/web/web.go b/golden/go/web/web.go
index e5d41d0..465ac50 100644
--- a/golden/go/web/web.go
+++ b/golden/go/web/web.go
@@ -3,6 +3,7 @@
import (
"context"
"encoding/json"
+ "fmt"
"net/http"
"net/url"
"sort"
@@ -471,17 +472,18 @@
return
}
- clID, ok := mux.Vars(r)["id"]
+ requestVars := mux.Vars(r)
+ clID, ok := requestVars["id"]
if !ok {
http.Error(w, "Must specify 'id' of ChangeList.", http.StatusBadRequest)
return
}
- psID, ok := mux.Vars(r)["patchset"]
+ psID, ok := requestVars["patchset"]
if !ok {
http.Error(w, "Must specify 'patchset' of ChangeList.", http.StatusBadRequest)
return
}
- crs, ok := mux.Vars(r)["system"]
+ crs, ok := requestVars["system"]
if !ok {
http.Error(w, "Must specify 'system' of ChangeList.", http.StatusBadRequest)
return
@@ -1559,6 +1561,57 @@
sendJSONResponse(w, digestsByTraceId)
}
+// ChangeListSearchRedirect redirects the user to a search page showing the search results
+// for a given CL. It will do a quick scan of the untriaged digests - if it finds some, it will
+// include the corpus containing some of those untriaged digests in the search query so the user
+// will see results (instead of getting directed to a corpus with no results).
+func (wh *Handlers) ChangeListSearchRedirect(w http.ResponseWriter, r *http.Request) {
+ defer metrics2.FuncTimer().Stop()
+ if err := wh.cheapLimitForAnonUsers(r); err != nil {
+ httputils.ReportError(w, err, "Try again later", http.StatusInternalServerError)
+ }
+
+ requestVars := mux.Vars(r)
+ crs, ok := requestVars["system"]
+ if !ok {
+ http.Error(w, "Must specify 'system' of ChangeList.", http.StatusBadRequest)
+ return
+ }
+ clID, ok := requestVars["id"]
+ if !ok {
+ http.Error(w, "Must specify 'id' of ChangeList.", http.StatusBadRequest)
+ return
+ }
+
+ baseURL := fmt.Sprintf("/search?issue=%s", clID)
+
+ clIdx := wh.Indexer.GetIndexForCL(crs, clID)
+ if clIdx == nil {
+ // Not cached, so we can't cheaply determine the corpus to include
+ if _, err := wh.ChangeListStore.GetChangeList(r.Context(), clID); err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect)
+ return
+ }
+
+ digestList, err := wh.SearchAPI.UntriagedUnignoredTryJobExclusiveDigests(r.Context(), clIdx.LatestPatchSet)
+ if err != nil {
+ sklog.Errorf("Could not find corpus to redirect to for CL %s: %s", clID, err)
+ http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect)
+ return
+ }
+ if len(digestList.Corpora) == 0 {
+ http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect)
+ return
+ }
+
+ withCorpus := baseURL + "&query=source_type%3D" + digestList.Corpora[0]
+ sklog.Debugf("Redirecting to %s", withCorpus)
+ http.Redirect(w, r, withCorpus, http.StatusTemporaryRedirect)
+}
+
func (wh *Handlers) now() time.Time {
if !wh.testingNow.IsZero() {
return wh.testingNow
diff --git a/golden/go/web/web_test.go b/golden/go/web/web_test.go
index 200b719..9364d29 100644
--- a/golden/go/web/web_test.go
+++ b/golden/go/web/web_test.go
@@ -38,6 +38,8 @@
mock_indexer "go.skia.org/infra/golden/go/indexer/mocks"
"go.skia.org/infra/golden/go/mocks"
"go.skia.org/infra/golden/go/paramsets"
+ search_fe "go.skia.org/infra/golden/go/search/frontend"
+ mock_search "go.skia.org/infra/golden/go/search/mocks"
bug_revert "go.skia.org/infra/golden/go/testutils/data_bug_revert"
"go.skia.org/infra/golden/go/tiling"
"go.skia.org/infra/golden/go/tjstore"
@@ -1949,6 +1951,155 @@
assertJSONResponseWas(t, http.StatusOK, expectedResponse, w)
}
+func TestChangeListSearchRedirect_CLHasUntriagedDigests_Success(t *testing.T) {
+ unittest.SmallTest(t)
+
+ mockSearchAPI := &mock_search.SearchAPI{}
+ mockIndexSource := &mock_indexer.IndexSource{}
+
+ const crs = "gerrit"
+ const clID = "1234"
+
+ combinedID := tjstore.CombinedPSID{
+ CRS: crs,
+ CL: clID,
+ PS: "some patchset",
+ }
+
+ mockIndexSource.On("GetIndexForCL", crs, clID).Return(&indexer.ChangeListIndex{
+ LatestPatchSet: combinedID,
+ // Other fields should be ignored
+ })
+
+ mockSearchAPI.On("UntriagedUnignoredTryJobExclusiveDigests", testutils.AnyContext, combinedID).Return(&search_fe.UntriagedDigestList{
+ Corpora: []string{"one_corpus", "two_corpus"},
+ }, nil)
+
+ wh := Handlers{
+ HandlersConfig: HandlersConfig{
+ Indexer: mockIndexSource,
+ SearchAPI: mockSearchAPI,
+ },
+ anonymousCheapQuota: rate.NewLimiter(rate.Inf, 1),
+ }
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/cl/gerrit/1234", nil)
+ r = mux.SetURLVars(r, map[string]string{
+ "system": crs,
+ "id": clID,
+ })
+ wh.ChangeListSearchRedirect(w, r)
+ assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
+ headers := w.Header()
+ assert.Equal(t, []string{"/search?issue=1234&query=source_type%3Done_corpus"}, headers["Location"])
+}
+
+func TestChangeListSearchRedirect_ErrorGettingUntriagedDigests_RedirectsWithoutCorpus(t *testing.T) {
+ unittest.SmallTest(t)
+
+ mockSearchAPI := &mock_search.SearchAPI{}
+ mockIndexSource := &mock_indexer.IndexSource{}
+ defer mockSearchAPI.AssertExpectations(t) // make sure the error was returned
+
+ const crs = "gerrit"
+ const clID = "1234"
+
+ combinedID := tjstore.CombinedPSID{
+ CRS: crs,
+ CL: clID,
+ PS: "some patchset",
+ }
+
+ mockIndexSource.On("GetIndexForCL", crs, clID).Return(&indexer.ChangeListIndex{
+ LatestPatchSet: combinedID,
+ // Other fields should be ignored
+ })
+
+ mockSearchAPI.On("UntriagedUnignoredTryJobExclusiveDigests", testutils.AnyContext, combinedID).Return(nil, errors.New("this shouldn't happen"))
+
+ wh := Handlers{
+ HandlersConfig: HandlersConfig{
+ Indexer: mockIndexSource,
+ SearchAPI: mockSearchAPI,
+ },
+ anonymousCheapQuota: rate.NewLimiter(rate.Inf, 1),
+ }
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/cl/gerrit/1234", nil)
+ r = mux.SetURLVars(r, map[string]string{
+ "system": crs,
+ "id": clID,
+ })
+ wh.ChangeListSearchRedirect(w, r)
+ assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
+ headers := w.Header()
+ assert.Equal(t, []string{"/search?issue=1234"}, headers["Location"])
+}
+
+func TestChangeListSearchRedirect_CLExistsNotIndexed_RedirectsWithoutCorpus(t *testing.T) {
+ unittest.SmallTest(t)
+
+ mockIndexSource := &mock_indexer.IndexSource{}
+ mockCLStore := &mock_clstore.Store{}
+
+ const crs = "gerrit"
+ const clID = "1234"
+
+ mockIndexSource.On("GetIndexForCL", crs, clID).Return(nil)
+
+ // all this cares about is a non-error return code
+ mockCLStore.On("GetChangeList", testutils.AnyContext, clID).Return(code_review.ChangeList{}, nil)
+
+ wh := Handlers{
+ HandlersConfig: HandlersConfig{
+ Indexer: mockIndexSource,
+ ChangeListStore: mockCLStore,
+ },
+ anonymousCheapQuota: rate.NewLimiter(rate.Inf, 1),
+ }
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/cl/gerrit/1234", nil)
+ r = mux.SetURLVars(r, map[string]string{
+ "system": crs,
+ "id": clID,
+ })
+ wh.ChangeListSearchRedirect(w, r)
+ assert.Equal(t, http.StatusTemporaryRedirect, w.Code)
+ headers := w.Header()
+ assert.Equal(t, []string{"/search?issue=1234"}, headers["Location"])
+}
+
+func TestChangeListSearchRedirect_CLDoesNotExist_404Error(t *testing.T) {
+ unittest.SmallTest(t)
+
+ mockIndexSource := &mock_indexer.IndexSource{}
+ mockCLStore := &mock_clstore.Store{}
+
+ const crs = "gerrit"
+ const clID = "1234"
+
+ mockIndexSource.On("GetIndexForCL", crs, clID).Return(nil)
+
+ // all this cares about is a non-error return code
+ mockCLStore.On("GetChangeList", testutils.AnyContext, clID).Return(code_review.ChangeList{}, code_review.ErrNotFound)
+
+ wh := Handlers{
+ HandlersConfig: HandlersConfig{
+ Indexer: mockIndexSource,
+ ChangeListStore: mockCLStore,
+ },
+ anonymousCheapQuota: rate.NewLimiter(rate.Inf, 1),
+ }
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(http.MethodGet, "/cl/gerrit/1234", nil)
+ r = mux.SetURLVars(r, map[string]string{
+ "system": crs,
+ "id": clID,
+ })
+ wh.ChangeListSearchRedirect(w, r)
+ assert.Equal(t, http.StatusNotFound, w.Code)
+}
+
// Because we are calling our handlers directly, the target URL doesn't matter. The target URL
// would only matter if we were calling into the router, so it knew which handler to call.
const requestURL = "/does/not/matter"