[gold] Update /json/v2/details RPC endpoint and details page to take a grouping as input.

I don't think there are any places that use this RPC besides the details page, so it's probably safe to modify it "in place" rather than creating a /json/v3/details RPC.

Bug: skia:13712
Change-Id: Ibc3b21b6f66fd729926b339e4736edb727259b47
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/578679
Reviewed-by: Kevin Lubick <kjlubick@google.com>
Commit-Queue: Leandro Lovisolo <lovisolo@google.com>
diff --git a/golden/cmd/gold_frontend/gold_frontend.go b/golden/cmd/gold_frontend/gold_frontend.go
index 1c6c022..7079a26 100644
--- a/golden/cmd/gold_frontend/gold_frontend.go
+++ b/golden/cmd/gold_frontend/gold_frontend.go
@@ -499,7 +499,7 @@
 	add("/json/v2/clusterdiff", handlers.ClusterDiffHandler, "GET")
 	add("/json/v2/commits", handlers.CommitsHandler, "GET")
 	add("/json/v1/positivedigestsbygrouping/{groupingID}", handlers.PositiveDigestsByGroupingIDHandler, "GET")
-	add("/json/v2/details", handlers.DetailsHandler, "GET")
+	add("/json/v2/details", handlers.DetailsHandler, "POST")
 	add("/json/v2/diff", handlers.DiffHandler, "POST")
 	add("/json/v2/digests", handlers.DigestListHandler, "GET")
 	add("/json/v2/latestpositivedigest/{traceID}", handlers.LatestPositiveDigestHandler, "GET")
diff --git a/golden/go/web/frontend/generate_typescript_rpc_types/main.go b/golden/go/web/frontend/generate_typescript_rpc_types/main.go
index 2af302c..0de8ac6 100644
--- a/golden/go/web/frontend/generate_typescript_rpc_types/main.go
+++ b/golden/go/web/frontend/generate_typescript_rpc_types/main.go
@@ -89,7 +89,10 @@
 	// Response for the /json/v1/diff RPC endpoint.
 	generator.Add(frontend.DigestComparison{})
 
-	// Response for the /json/v1/details RPC endpoint.
+	// Request for the /json/v2/details RPC endpoint.
+	generator.Add(frontend.DetailsRequest{})
+
+	// Response for the /json/v2/details RPC endpoint.
 	generator.Add(frontend.DigestDetails{})
 
 	// Response for the /json/v1/clusterdiff RPC endpoint.
diff --git a/golden/go/web/frontend/types.go b/golden/go/web/frontend/types.go
index 9168390..ac867dd 100644
--- a/golden/go/web/frontend/types.go
+++ b/golden/go/web/frontend/types.go
@@ -705,3 +705,11 @@
 type GroupingForTestResponse struct {
 	Grouping paramtools.Params `json:"grouping"`
 }
+
+// DetailsRequest is the request for the /json/v2/details RPC.
+type DetailsRequest struct {
+	Grouping         paramtools.Params `json:"grouping"`
+	Digest           types.Digest      `json:"digest"`
+	ChangelistID     string            `json:"changelist_id,omitempty"`
+	CodeReviewSystem string            `json:"crs,omitempty"`
+}
diff --git a/golden/go/web/web.go b/golden/go/web/web.go
index 4a99e07..b4deadd 100644
--- a/golden/go/web/web.go
+++ b/golden/go/web/web.go
@@ -469,35 +469,29 @@
 		return
 	}
 
-	// Extract: test, digest, issue
-	if err := r.ParseForm(); err != nil {
-		httputils.ReportError(w, err, "Failed to parse form values", http.StatusInternalServerError)
+	req := frontend.DetailsRequest{}
+	if err := parseJSON(r, &req); err != nil {
+		httputils.ReportError(w, err, "Failed to parse JSON request.", http.StatusBadRequest)
 		return
 	}
-	// TODO(kjlubick) require corpus
-	test := r.Form.Get("test")
-	digest := r.Form.Get("digest")
-	if test == "" || !validation.IsValidDigest(digest) {
-		http.Error(w, "Some query parameters are wrong or missing", http.StatusBadRequest)
+	sklog.Infof("Details request: %#v", req)
+
+	if len(req.Grouping) == 0 {
+		http.Error(w, "Grouping cannot be empty.", http.StatusBadRequest)
 		return
 	}
-	clID := r.Form.Get("changelist_id")
-	crs := r.Form.Get("crs")
-	if clID != "" {
-		if _, ok := wh.getCodeReviewSystem(crs); !ok {
-			http.Error(w, "Invalid Code Review System; did you include crs?", http.StatusBadRequest)
+	if !validation.IsValidDigest(string(req.Digest)) {
+		http.Error(w, "Invalid digest.", http.StatusBadRequest)
+		return
+	}
+	if req.CodeReviewSystem != "" && req.ChangelistID != "" {
+		if _, ok := wh.getCodeReviewSystem(req.CodeReviewSystem); !ok {
+			http.Error(w, "Invalid code review system.", http.StatusBadRequest)
 			return
 		}
-	} else {
-		crs = ""
 	}
 
-	grouping, err := wh.getGroupingForTest(ctx, test)
-	if err != nil {
-		httputils.ReportError(w, err, "could not get grouping", http.StatusInternalServerError)
-		return
-	}
-	ret, err := wh.Search2API.GetDigestDetails(ctx, grouping, types.Digest(digest), clID, crs)
+	ret, err := wh.Search2API.GetDigestDetails(ctx, req.Grouping, types.Digest(req.Digest), req.ChangelistID, req.CodeReviewSystem)
 	if err != nil {
 		httputils.ReportError(w, err, "Unable to get digest details.", http.StatusInternalServerError)
 		return
diff --git a/golden/go/web/web_test.go b/golden/go/web/web_test.go
index 400a2d5..ef0289c 100644
--- a/golden/go/web/web_test.go
+++ b/golden/go/web/web_test.go
@@ -3238,6 +3238,128 @@
 	assertJSONResponseWas(t, http.StatusOK, `{"grouping":{"name":"square","source_type":"corners"}}`, w)
 }
 
+func TestDetailsHandler_InvalidRequest_Error(t *testing.T) {
+	wh := Handlers{
+		anonymousCheapQuota: rate.NewLimiter(rate.Inf, 1),
+		HandlersConfig: HandlersConfig{
+			ReviewSystems: []clstore.ReviewSystem{
+				{
+					ID: dks.GerritCRS,
+				},
+			},
+		},
+	}
+
+	test := func(name string, req frontend.DetailsRequest, expectedError string) {
+		t.Run(name, func(t *testing.T) {
+			reqBytes, err := json.Marshal(req)
+			require.NoError(t, err)
+
+			w := httptest.NewRecorder()
+			r := httptest.NewRequest(http.MethodPost, "/json/v2/details", bytes.NewReader(reqBytes))
+			wh.DetailsHandler(w, r)
+
+			res := w.Result()
+			resBytes, err := io.ReadAll(w.Body)
+			require.NoError(t, err)
+
+			assert.Equal(t, http.StatusBadRequest, res.StatusCode)
+			assert.Contains(t, string(resBytes), expectedError)
+		})
+	}
+
+	test("empty request", frontend.DetailsRequest{}, "Grouping cannot be empty.")
+	test(
+		"invalid digest",
+		frontend.DetailsRequest{
+			Grouping: paramtools.Params{
+				types.CorpusField:     "fake_corpus",
+				types.PrimaryKeyField: "fake_test",
+			},
+			Digest: "invalid digest",
+		},
+		"Invalid digest.")
+	test(
+		"invalid code review system",
+		frontend.DetailsRequest{
+			Grouping: paramtools.Params{
+				types.CorpusField:     "fake_corpus",
+				types.PrimaryKeyField: "fake_test",
+			},
+			Digest:           "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+			CodeReviewSystem: "invalid code review system",
+			ChangelistID:     "123456",
+		},
+		"Invalid code review system.")
+}
+
+func TestDetailsHandler_ValidRequest_Success(t *testing.T) {
+	wh := Handlers{
+		anonymousCheapQuota: rate.NewLimiter(rate.Inf, 1),
+		HandlersConfig: HandlersConfig{
+			ReviewSystems: []clstore.ReviewSystem{
+				{
+					ID: dks.GerritCRS,
+				},
+			},
+		},
+	}
+
+	test := func(name string, req frontend.DetailsRequest, expectedResponse string) {
+		t.Run(name, func(t *testing.T) {
+			ms := &mock_search.API{}
+
+			ms.On("GetDigestDetails", testutils.AnyContext, req.Grouping, req.Digest, req.ChangelistID, req.CodeReviewSystem).
+				Return(frontend.DigestDetails{
+					Result: frontend.SearchResult{
+						Digest: req.Digest,
+						Test:   types.TestName(req.Grouping[types.PrimaryKeyField]),
+					},
+					Commits: []frontend.Commit{},
+				}, nil)
+			wh.HandlersConfig.Search2API = ms
+
+			reqBytes, err := json.Marshal(req)
+			require.NoError(t, err)
+
+			w := httptest.NewRecorder()
+			r := httptest.NewRequest(http.MethodPost, "/json/v2/details", bytes.NewReader(reqBytes))
+			wh.DetailsHandler(w, r)
+
+			assertJSONResponseWas(t, http.StatusOK, expectedResponse, w)
+		})
+	}
+
+	test(
+		"without crs/cl",
+		frontend.DetailsRequest{
+			Grouping: paramtools.Params{
+				types.CorpusField:     dks.CornersCorpus,
+				types.PrimaryKeyField: dks.SquareTest,
+			},
+			Digest: dks.DigestA01Pos,
+		},
+		`{"digest":{"digest":"a01a01a01a01a01a01a01a01a01a01a0","test":"square",`+
+			`"status":"","triage_history":null,"paramset":null,`+
+			`"traces":{"traces":null,"digests":null,"total_digests":0},`+
+			`"refDiffs":null,"closestRef":""},"commits":[]}`)
+	test(
+		"with crs/cl",
+		frontend.DetailsRequest{
+			Grouping: paramtools.Params{
+				types.CorpusField:     dks.RoundCorpus,
+				types.PrimaryKeyField: dks.CircleTest,
+			},
+			Digest:           dks.DigestC01Pos,
+			CodeReviewSystem: dks.GerritCRS,
+			ChangelistID:     dks.ChangelistIDThatAttemptsToFixIOS,
+		},
+		`{"digest":{"digest":"c01c01c01c01c01c01c01c01c01c01c0","test":"circle",`+
+			`"status":"","triage_history":null,"paramset":null,`+
+			`"traces":{"traces":null,"digests":null,"total_digests":0},`+
+			`"refDiffs":null,"closestRef":""},"commits":[]}`)
+}
+
 // 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"
diff --git a/golden/modules/details-page-sk/details-page-sk-demo.ts b/golden/modules/details-page-sk/details-page-sk-demo.ts
index 8a2bc7a..ba4db32 100644
--- a/golden/modules/details-page-sk/details-page-sk-demo.ts
+++ b/golden/modules/details-page-sk/details-page-sk-demo.ts
@@ -3,17 +3,15 @@
 
 import { $$ } from 'common-sk/modules/dom';
 import fetchMock from 'fetch-mock';
-import { toObject } from 'common-sk/modules/query';
-import { HintableObject } from 'common-sk/modules/hintable';
-import {
-  fakeNow, twoHundredCommits, makeTypicalSearchResult,
-} from '../digest-details-sk/test_data';
+import { fakeNow, twoHundredCommits, makeTypicalSearchResult } from '../digest-details-sk/test_data';
 import { delay } from '../demo_util';
 import { testOnlySetSettings } from '../settings';
 import { exampleStatusData } from '../last-commit-sk/demo_data';
 import { GoldScaffoldSk } from '../gold-scaffold-sk/gold-scaffold-sk';
 import { DetailsPageSk } from './details-page-sk';
-import { DigestDetails } from '../rpc_types';
+import {
+  DetailsRequest, DigestDetails, GroupingForTestRequest, GroupingForTestResponse,
+} from '../rpc_types';
 import { setQueryString } from '../../../infra-sk/modules/test_util';
 import { groupingsResponse } from '../search-page-sk/demo_data';
 
@@ -25,28 +23,33 @@
 // Load the demo page with some params to load if there aren't any already.
 if (window.location.search.length === 0) {
   setQueryString(
-    '?digest=6246b773851984c726cb2e1cb13510c2&test=This%20is%20a%20test%20with%20spaces&'
+    '?digest=6246b773851984c726cb2e1cb13510c2&'
+    + 'grouping=name%3DThis%2520is%2520a%2520test%2520with%2520spaces%26source_type%3Dinfra&'
       + 'changelist_id=12353&crs=gerrit-internal',
   );
 }
 
 Date.now = () => fakeNow;
 
-interface UrlParams {
-  digest: string;
-  test: string;
-}
-
 fetchMock.get('/json/v1/groupings', groupingsResponse);
-fetchMock.get('glob:/json/v2/details*', (url) => {
+fetchMock.post('/json/v1/groupingfortest', (url, opts) => {
+  const request: GroupingForTestRequest = JSON.parse(opts.body!.toString());
+  const response: GroupingForTestResponse = {
+    grouping: {
+      name: request.test_name,
+      source_type: 'infra',
+    },
+  };
+  return response;
+});
+
+fetchMock.post('/json/v2/details', (url, opts) => {
   if ($$<HTMLInputElement>('#simulate-rpc-error')!.checked) {
     return 500;
   }
 
-  // Make a response based on the URL parameters. This is needed by the Puppeteer test.
-  const hint: UrlParams = { digest: '', test: '' };
-  const urlParams = toObject(url.split('?')[1], hint as unknown as HintableObject) as unknown as UrlParams;
-
+  // Make a response based on the RPC request. This is needed by the Puppeteer test.
+  const request: DetailsRequest = JSON.parse(opts.body!.toString());
   if ($$<HTMLInputElement>('#simulate-not-found-in-index')!.checked) {
     const response: DigestDetails = {
       digest: {
@@ -69,9 +72,9 @@
   }
   const knownDigest1 = '99c58c7002073346ff55f446d47d6311';
   const knownDigest2 = '6246b773851984c726cb2e1cb13510c2';
-  const closestDigest = urlParams.digest === knownDigest1 ? knownDigest2 : knownDigest1;
+  const closestDigest = request.digest === knownDigest1 ? knownDigest2 : knownDigest1;
   const response: DigestDetails = {
-    digest: makeTypicalSearchResult(urlParams.test, urlParams.digest, closestDigest),
+    digest: makeTypicalSearchResult(request.grouping.name, request.digest, closestDigest),
     commits: twoHundredCommits,
   };
   return delay(response);
diff --git a/golden/modules/details-page-sk/details-page-sk.ts b/golden/modules/details-page-sk/details-page-sk.ts
index d0133d1..2e77ef8 100644
--- a/golden/modules/details-page-sk/details-page-sk.ts
+++ b/golden/modules/details-page-sk/details-page-sk.ts
@@ -13,7 +13,7 @@
 import '../digest-details-sk';
 import { sendBeginTask, sendEndTask, sendFetchError } from '../common';
 import {
-  Commit, DigestDetails, GroupingsResponse, SearchResult,
+  Commit, DetailsRequest, DigestDetails, GroupingForTestRequest, GroupingForTestResponse, GroupingsResponse, Params, SearchResult,
 } from '../rpc_types';
 
 export class DetailsPageSk extends ElementSk {
@@ -22,9 +22,12 @@
       return html`<h1>Loading...</h1>`;
     }
     if (!ele.details?.digest) {
+      const testName = Object.keys(ele.grouping).length === 0
+        ? ele.testName
+        : ele.grouping.name;
       return html`
         <div>
-          Could not load details for digest ${ele.digest} and test "${ele.grouping}".
+          Could not load details for digest ${ele.digest} and test "${testName}".
           <br>
           It might not exist or be too new so as not to be indexed yet.
         </div>
@@ -42,7 +45,9 @@
 
   private groupings: GroupingsResponse | null = null;
 
-  private grouping = '';
+  private grouping: Params = {};
+
+  private testName = ''; // TODO(lovisolo): Delete once all inbound links include a grouping.
 
   private digest = '';
 
@@ -67,7 +72,8 @@
     this.stateChanged = stateReflector(
       /* getState */() => ({
         // provide empty values
-        test: this.grouping, // TODO(kjlubick) rename test -> grouping
+        grouping: this.grouping,
+        test: this.testName, // TODO(lovisolo): Delete once all inbound links include a grouping.
         digest: this.digest,
         changelist_id: this.changeListID,
         crs: this.crs,
@@ -76,7 +82,9 @@
           return;
         }
         // default values if not specified.
-        this.grouping = newState.test as string || '';
+        this.grouping = newState.grouping as Params || {};
+        // TODO(lovisolo): Delete once all inbound links include a grouping.
+        this.testName = newState.test as string || '';
         this.digest = newState.digest as string || '';
         this.changeListID = newState.changelist_id as string || '';
         this.crs = newState.crs as string || '';
@@ -106,7 +114,7 @@
     }
   }
 
-  private fetchDigestDetails() {
+  private async fetchDigestDetails() {
     if (this.fetchController) {
       // Kill any outstanding requests
       this.fetchController.abort();
@@ -115,17 +123,46 @@
     // Make a fresh abort controller for each set of fetches.
     // They cannot be re-used once aborted.
     this.fetchController = new AbortController();
-    const extra = {
-      signal: this.fetchController.signal,
-    };
     sendBeginTask(this);
-    const urlBase = '/json/v2/details';
 
-    const url = `${urlBase}?test=${encodeURIComponent(this.grouping)}`
-      + `&digest=${encodeURIComponent(this.digest)}&changelist_id=${this.changeListID}`
-      + `&crs=${this.crs}`;
-    fetch(url, extra)
-      .then(jsonOrThrow)
+    // Fetch grouping if not specified as an URL parameter.
+    if (Object.keys(this.grouping).length === 0) {
+      const request: GroupingForTestRequest = {
+        test_name: this.testName,
+      };
+      try {
+        const response: GroupingForTestResponse = await fetch('/json/v1/groupingfortest', {
+          method: 'POST',
+          body: JSON.stringify(request),
+          headers: {
+            'Content-Type': 'application/json',
+          },
+        }).then(jsonOrThrow);
+        this.grouping = response.grouping;
+      } catch (e) {
+        this._render();
+        sendFetchError(this, e, 'fetching grouping for test');
+        return;
+      }
+    }
+
+    const request: DetailsRequest = {
+      digest: this.digest,
+      grouping: this.grouping,
+    };
+    if (this.changeListID && this.crs) {
+      request.changelist_id = this.changeListID;
+      request.crs = this.crs;
+    }
+
+    fetch('/json/v2/details', {
+      method: 'POST',
+      body: JSON.stringify(request),
+      headers: {
+        'Content-Type': 'application/json',
+      },
+      signal: this.fetchController.signal,
+    }).then(jsonOrThrow)
       .then((digestDetails: DigestDetails) => {
         this.commits = digestDetails.commits || [];
         this.details = digestDetails.digest;
diff --git a/golden/modules/details-page-sk/details-page-sk_test.ts b/golden/modules/details-page-sk/details-page-sk_test.ts
index 86e76cd..1027cb6 100644
--- a/golden/modules/details-page-sk/details-page-sk_test.ts
+++ b/golden/modules/details-page-sk/details-page-sk_test.ts
@@ -2,8 +2,12 @@
 import fetchMock from 'fetch-mock';
 import { expect } from 'chai';
 import { deepCopy } from 'common-sk/modules/object';
-import { eventPromise, eventSequencePromise, setUpElementUnderTest } from '../../../infra-sk/modules/test_util';
-import { DigestDetails, TriageRequestV3, TriageResponse } from '../rpc_types';
+import {
+  eventPromise, eventSequencePromise, setQueryString, setUpElementUnderTest,
+} from '../../../infra-sk/modules/test_util';
+import {
+  DigestDetails, GroupingForTestRequest, GroupingForTestResponse, TriageRequestV3, TriageResponse,
+} from '../rpc_types';
 import { DetailsPageSk } from './details-page-sk';
 import { DetailsPageSkPO } from './details-page-sk_po';
 import { groupingsResponse } from '../search-page-sk/demo_data';
@@ -15,50 +19,100 @@
   let detailsPageSk: DetailsPageSk;
   let detailsPageSkPO: DetailsPageSkPO;
 
-  beforeEach(async () => {
+  const initialize = async (queryString: string, useGroupingForTestRPC: boolean) => {
+    setQueryString(queryString);
+
     detailsPageSk = newInstance();
     detailsPageSkPO = new DetailsPageSkPO(detailsPageSk);
 
     fetchMock.getOnce('/json/v1/groupings', () => deepCopy(groupingsResponse));
+    if (useGroupingForTestRPC) {
+      fetchMock.post('/json/v1/groupingfortest', (url, opts) => {
+        const request: GroupingForTestRequest = JSON.parse(opts.body!.toString());
+        const response: GroupingForTestResponse = {
+          grouping: {
+            name: request.test_name,
+            source_type: 'infra',
+          },
+        };
+        return response;
+      });
+    }
     const digestDetails: DigestDetails = {
       digest: deepCopy(typicalDetails),
       commits: deepCopy(twoHundredCommits),
     };
-    fetchMock.get('glob:/json/v2/details*', digestDetails);
+    fetchMock.post('/json/v2/details', digestDetails);
 
     // Wait for the above RPCs to complete.
     await eventSequencePromise(['end-task', 'end-task']);
-  });
+  };
+
+  const addTests = () => {
+    it('renders', async () => {
+      expect(await detailsPageSkPO.digestDetailsSkPO.getTestName())
+        .to.equal('Test: dots-legend-sk_too-many-digests');
+    });
+
+    it('can triage', async () => {
+      // This tests the wiring that passes the groupings returned by the /json/v1/groupings RPC to
+      // the digest-details-sk element.
+      const triageRequest: TriageRequestV3 = {
+        deltas: [
+          {
+            grouping: {
+              source_type: 'infra',
+              name: 'dots-legend-sk_too-many-digests',
+            },
+            digest: '6246b773851984c726cb2e1cb13510c2',
+            label_before: 'positive',
+            label_after: 'negative',
+          },
+        ],
+        changelist_id: '12353',
+        crs: 'gerrit-internal',
+      };
+      const triageResponse: TriageResponse = { status: 'ok' };
+      fetchMock.post(
+        { url: '/json/v3/triage', body: triageRequest },
+        { status: 200, body: triageResponse },
+      );
+
+      const endTask = eventPromise('end-task');
+      await detailsPageSkPO.digestDetailsSkPO.triageSkPO.clickButton('negative');
+      await endTask;
+    });
+  };
 
   afterEach(() => {
     expect(fetchMock.done()).to.be.true; // All mock RPCs called at least once.
     fetchMock.reset();
   });
 
-  it('can triage', async () => {
-    // This tests the wiring that passes the groupings returned by the /json/v1/groupings RPC to
-    // the digest-details-sk element.
-    const triageRequest: TriageRequestV3 = {
-      deltas: [
-        {
-          grouping: {
-            source_type: 'infra',
-            name: 'dots-legend-sk_too-many-digests',
-          },
-          digest: '6246b773851984c726cb2e1cb13510c2',
-          label_before: 'positive',
-          label_after: 'negative',
-        },
-      ],
-    };
-    const triageResponse: TriageResponse = { status: 'ok' };
-    fetchMock.post(
-      { url: '/json/v3/triage', body: triageRequest },
-      { status: 200, body: triageResponse },
-    );
+  describe('with grouping in URL', () => {
+    beforeEach(async () => {
+      await initialize(
+        '?digest=6246b773851984c726cb2e1cb13510c2&'
+          + 'grouping=name%3Ddots-legend-sk_too-many-digests%26source_type%3Dinfra&'
+          + 'changelist_id=12353&crs=gerrit-internal',
+        /* useGroupingForTestRPC= */ false,
+      );
+    });
 
-    const endTask = eventPromise('end-task');
-    await detailsPageSkPO.digestDetailsSkPO.triageSkPO.clickButton('negative');
-    await endTask;
+    addTests();
+  });
+
+  // TODO(lovisolo): Delete once all inbound links include a grouping.
+  describe('legacy links with just test in URL', () => {
+    beforeEach(async () => {
+      await initialize(
+        '?digest=6246b773851984c726cb2e1cb13510c2&'
+          + 'test=dots-legend-sk_too-many-digests&'
+          + 'changelist_id=12353&crs=gerrit-internal',
+        /* useGroupingForTestRPC= */ true,
+      );
+    });
+
+    addTests();
   });
 });
diff --git a/golden/modules/rpc_types.ts b/golden/modules/rpc_types.ts
index ef4be74..457917f 100644
--- a/golden/modules/rpc_types.ts
+++ b/golden/modules/rpc_types.ts
@@ -234,6 +234,13 @@
 	right: SRDiffDigest;
 }
 
+export interface DetailsRequest {
+	grouping: Params;
+	digest: Digest;
+	changelist_id?: string;
+	crs?: string;
+}
+
 export interface DigestDetails {
 	digest: SearchResult;
 	commits: Commit[] | null;