diff --git a/ui/src/hooks/useIssueExternalObjects.test.ts b/ui/src/hooks/useIssueExternalObjects.test.ts new file mode 100644 index 00000000000..0dc3f0e06ea --- /dev/null +++ b/ui/src/hooks/useIssueExternalObjects.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ExternalObjectSummary } from "@paperclipai/shared"; +import { externalObjectsApi } from "../api/externalObjects"; +import { + EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE, + fetchIssueExternalObjectSummariesInBatches, +} from "./useIssueExternalObjects"; + +vi.mock("../api/externalObjects", () => ({ + externalObjectsApi: { + getIssueSummaries: vi.fn(), + }, +})); + +const emptySummary: ExternalObjectSummary = { + total: 0, + byStatusCategory: {}, + byLiveness: {}, + highestSeverity: "neutral", + objects: [], +}; + +describe("fetchIssueExternalObjectSummariesInBatches", () => { + afterEach(() => { + vi.mocked(externalObjectsApi.getIssueSummaries).mockReset(); + }); + + it("chunks bulk summary requests below the server validation cap and merges results", async () => { + const issueIds = Array.from( + { length: EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE * 2 + 3 }, + (_entry, index) => `issue-${index}`, + ); + vi.mocked(externalObjectsApi.getIssueSummaries).mockImplementation(async (_companyId, ids) => ({ + summaries: Object.fromEntries(ids.map((id) => [id, { ...emptySummary, total: id.endsWith("-0") ? 1 : 0 }])), + })); + + const result = await fetchIssueExternalObjectSummariesInBatches("company-1", issueIds); + + expect(externalObjectsApi.getIssueSummaries).toHaveBeenCalledTimes(3); + expect(vi.mocked(externalObjectsApi.getIssueSummaries).mock.calls.map((call) => call[1].length)).toEqual([ + EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE, + EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE, + 3, + ]); + expect(Object.keys(result.summaries)).toHaveLength(issueIds.length); + expect(result.summaries["issue-0"]?.total).toBe(1); + }); +}); diff --git a/ui/src/hooks/useIssueExternalObjects.ts b/ui/src/hooks/useIssueExternalObjects.ts index 3e1a9bf4648..8f8052afb0d 100644 --- a/ui/src/hooks/useIssueExternalObjects.ts +++ b/ui/src/hooks/useIssueExternalObjects.ts @@ -12,6 +12,21 @@ import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody"; import type { ExternalObjectPillData } from "../components/ExternalObjectPill"; import { instanceSettingsApi } from "../api/instanceSettings"; +export const EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE = 500; + +export async function fetchIssueExternalObjectSummariesInBatches( + companyId: string, + issueIds: readonly string[], +) { + const summaries: Record = {}; + for (let index = 0; index < issueIds.length; index += EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE) { + const batch = issueIds.slice(index, index + EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE); + const response = await externalObjectsApi.getIssueSummaries(companyId, batch); + Object.assign(summaries, response.summaries); + } + return { summaries }; +} + /** * Browser-side mention-source label. Keep in sync with the shared formatter * without coupling this hook to the server-only URL canonicalization helpers. @@ -201,7 +216,7 @@ export function useIssueExternalObjectSummaries( const enabled = externalObjectsFeature.isEnabled && Boolean(companyId) && normalizedIssueIds.length > 0; const query = useQuery({ queryKey: queryKeys.externalObjects.issueSummaries(companyId ?? "__none__", normalizedIssueIds), - queryFn: () => externalObjectsApi.getIssueSummaries(companyId!, normalizedIssueIds), + queryFn: () => fetchIssueExternalObjectSummariesInBatches(companyId!, normalizedIssueIds), enabled, staleTime: 60_000, });