From 601583b2ad419756f783bd95624dbe0f6fd4705d Mon Sep 17 00:00:00 2001 From: cavidelizade Date: Fri, 17 Jul 2026 00:05:03 +0400 Subject: [PATCH] fix: page through issues instead of relying on an over-cap limit The issues list endpoint reset any limit above 100 back to 50 instead of clamping to 100, so the many callers that passed 200 to 2000 expecting to fetch a whole project silently got only 50 issues. That showed up as incomplete data on cycle, module and epic detail, saved views, intake, analytics and exports for any project with more than 50 issues. Two parts: - Backend: clamp limit to 100 instead of dropping to 50 when it's over the cap (issue list, drafts, archived). - Web: add issueService.listAll, which pages through in chunks of 100, and switch every "fetch all" caller (and the export modal loop) to it. Closes #335 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/internal/handler/issue.go | 12 +++++++++--- apps/api/internal/handler/issue_archive.go | 4 +++- .../components/AddExistingWorkItemModal.tsx | 2 +- apps/web/src/components/layout/PageHeader.tsx | 2 +- .../settings/modals/ExportModal.tsx | 3 ++- apps/web/src/pages/AnalyticsOverviewPage.tsx | 4 +--- apps/web/src/pages/AnalyticsWorkItemsPage.tsx | 2 +- apps/web/src/pages/CycleDetailPage.tsx | 4 ++-- apps/web/src/pages/CyclesPage.tsx | 4 ++-- apps/web/src/pages/EpicDetailPage.tsx | 2 +- apps/web/src/pages/IntakePage.tsx | 2 +- apps/web/src/pages/IssueDetailPage.tsx | 4 ++-- apps/web/src/pages/ModuleDetailPage.tsx | 4 ++-- apps/web/src/pages/ViewDetailPage.tsx | 4 ++-- apps/web/src/services/issueService.ts | 19 +++++++++++++++++++ 15 files changed, 49 insertions(+), 23 deletions(-) diff --git a/apps/api/internal/handler/issue.go b/apps/api/internal/handler/issue.go index c562294d..ea8dd218 100644 --- a/apps/api/internal/handler/issue.go +++ b/apps/api/internal/handler/issue.go @@ -49,8 +49,10 @@ func (h *IssueHandler) ListWorkspaceDrafts(c *gin.Context) { slug := c.Param("slug") limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) - if limit <= 0 || limit > 100 { + if limit <= 0 { limit = 50 + } else if limit > 100 { + limit = 100 } list, err := h.Issue.ListDraftsForWorkspace(c.Request.Context(), slug, user.ID, limit, offset) if err != nil { @@ -75,8 +77,10 @@ func (h *IssueHandler) ListWorkspaceArchived(c *gin.Context) { slug := c.Param("slug") limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) - if limit <= 0 || limit > 100 { + if limit <= 0 { limit = 50 + } else if limit > 100 { + limit = 100 } list, err := h.Issue.ListArchivedForWorkspace(c.Request.Context(), slug, user.ID, limit, offset) if err != nil { @@ -106,8 +110,10 @@ func (h *IssueHandler) List(c *gin.Context) { } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) - if limit <= 0 || limit > 100 { + if limit <= 0 { limit = 50 + } else if limit > 100 { + limit = 100 } list, err := h.Issue.List(c.Request.Context(), slug, projectID, user.ID, limit, offset) if err != nil { diff --git a/apps/api/internal/handler/issue_archive.go b/apps/api/internal/handler/issue_archive.go index eaea67cc..d6ce2b64 100644 --- a/apps/api/internal/handler/issue_archive.go +++ b/apps/api/internal/handler/issue_archive.go @@ -175,8 +175,10 @@ func (h *IssueHandler) ListArchived(c *gin.Context) { } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) - if limit <= 0 || limit > 100 { + if limit <= 0 { limit = 50 + } else if limit > 100 { + limit = 100 } list, err := h.Issue.ListArchived(c.Request.Context(), slug, projectID, user.ID, limit, offset) if err != nil { diff --git a/apps/web/src/components/AddExistingWorkItemModal.tsx b/apps/web/src/components/AddExistingWorkItemModal.tsx index c15962c4..42ed294f 100644 --- a/apps/web/src/components/AddExistingWorkItemModal.tsx +++ b/apps/web/src/components/AddExistingWorkItemModal.tsx @@ -59,7 +59,7 @@ export function AddExistingWorkItemModal({ setLoading(true); setError(null); Promise.all([ - issueService.list(workspaceSlug, projectId, { limit: 2000 }), + issueService.listAll(workspaceSlug, projectId), moduleService.listIssueIds(workspaceSlug, projectId, moduleId), ]) .then(([issues, ids]) => { diff --git a/apps/web/src/components/layout/PageHeader.tsx b/apps/web/src/components/layout/PageHeader.tsx index 112cea07..e5f64bc0 100644 --- a/apps/web/src/components/layout/PageHeader.tsx +++ b/apps/web/src/components/layout/PageHeader.tsx @@ -92,7 +92,7 @@ export function PageHeader() { .get(workspaceSlug, projectId) .then((p) => { if (!cancelled) setProject(p ?? null); - return p ? issueService.list(workspaceSlug, projectId, { limit: 1000 }) : []; + return p ? issueService.listAll(workspaceSlug, projectId) : []; }) .then((issues) => { if (!cancelled && Array.isArray(issues)) setProjectIssueCount(issues.length); diff --git a/apps/web/src/components/settings/modals/ExportModal.tsx b/apps/web/src/components/settings/modals/ExportModal.tsx index 77fc58a8..c1d142ad 100644 --- a/apps/web/src/components/settings/modals/ExportModal.tsx +++ b/apps/web/src/components/settings/modals/ExportModal.tsx @@ -86,7 +86,8 @@ export function ExportModal({ project_name?: string; } > = []; - const limit = 2000; + // The server caps `limit` at 100, so page through in 100s. + const limit = 100; for (const pid of projectIds) { const proj = projects.find((p) => p.id === pid); let offset = 0; diff --git a/apps/web/src/pages/AnalyticsOverviewPage.tsx b/apps/web/src/pages/AnalyticsOverviewPage.tsx index 243a6785..fcac38b2 100644 --- a/apps/web/src/pages/AnalyticsOverviewPage.tsx +++ b/apps/web/src/pages/AnalyticsOverviewPage.tsx @@ -56,9 +56,7 @@ export function AnalyticsOverviewPage() { setMembers(mem ?? []); } if (!cancelled && projs?.length) { - return Promise.all( - projs.map((p) => issueService.list(workspaceSlug!, p.id, { limit: 200 })), - ); + return Promise.all(projs.map((p) => issueService.listAll(workspaceSlug!, p.id))); } return []; }) diff --git a/apps/web/src/pages/AnalyticsWorkItemsPage.tsx b/apps/web/src/pages/AnalyticsWorkItemsPage.tsx index 902869a9..cded0e7f 100644 --- a/apps/web/src/pages/AnalyticsWorkItemsPage.tsx +++ b/apps/web/src/pages/AnalyticsWorkItemsPage.tsx @@ -126,7 +126,7 @@ export function AnalyticsWorkItemsPage() { if (!cancelled && projs?.length) setProjects(projs); if (!cancelled && projs?.length) { return Promise.all([ - ...projs.map((p) => issueService.list(workspaceSlug!, p.id, { limit: 200 })), + ...projs.map((p) => issueService.listAll(workspaceSlug!, p.id)), ...projs.map((p) => stateService.list(workspaceSlug!, p.id)), ]); } diff --git a/apps/web/src/pages/CycleDetailPage.tsx b/apps/web/src/pages/CycleDetailPage.tsx index d8df10b6..53cdd9a3 100644 --- a/apps/web/src/pages/CycleDetailPage.tsx +++ b/apps/web/src/pages/CycleDetailPage.tsx @@ -158,7 +158,7 @@ export function CycleDetailPage() { workspaceService.getBySlug(workspaceSlug), projectService.get(workspaceSlug, projectId), cycleService.list(workspaceSlug, projectId), - issueService.list(workspaceSlug, projectId, { limit: 1000 }), + issueService.listAll(workspaceSlug, projectId), stateService.list(workspaceSlug, projectId), labelService.list(workspaceSlug, projectId), moduleService.list(workspaceSlug, projectId), @@ -309,7 +309,7 @@ export function CycleDetailPage() { setCycle(res.cycle); // Some work items may have moved out; refresh the list and the snapshot. const [allIssues, snap] = await Promise.all([ - issueService.list(workspaceSlug, projectId, { limit: 500 }), + issueService.listAll(workspaceSlug, projectId), cycleService.getProgress(workspaceSlug, projectId, cycle.id), ]); setIssues(allIssues ?? []); diff --git a/apps/web/src/pages/CyclesPage.tsx b/apps/web/src/pages/CyclesPage.tsx index a2010b65..a2fe94ec 100644 --- a/apps/web/src/pages/CyclesPage.tsx +++ b/apps/web/src/pages/CyclesPage.tsx @@ -373,7 +373,7 @@ export function CyclesPage() { projectService.get(workspaceSlug, projectId), cycleService.list(workspaceSlug, projectId), workspaceService.listMembers(workspaceSlug), - issueService.list(workspaceSlug, projectId, { limit: 500 }), + issueService.listAll(workspaceSlug, projectId), stateService.list(workspaceSlug, projectId), labelService.list(workspaceSlug, projectId), ]) @@ -471,7 +471,7 @@ export function CyclesPage() { } Promise.all([ cycleService.list(workspaceSlug, projectId), - issueService.list(workspaceSlug, projectId, { limit: 500 }), + issueService.listAll(workspaceSlug, projectId), ]) .then(([list, iss]) => { setCycles(list ?? []); diff --git a/apps/web/src/pages/EpicDetailPage.tsx b/apps/web/src/pages/EpicDetailPage.tsx index 68f1ea41..fb70acfb 100644 --- a/apps/web/src/pages/EpicDetailPage.tsx +++ b/apps/web/src/pages/EpicDetailPage.tsx @@ -65,7 +65,7 @@ export function EpicDetailPage() { projectService.get(workspaceSlug, projectId), epicService.get(workspaceSlug, projectId, epicId), epicService.listIssues(workspaceSlug, projectId, epicId), - issueService.list(workspaceSlug, projectId, { limit: 250 }), + issueService.listAll(workspaceSlug, projectId), stateService.list(workspaceSlug, projectId), epicService.listLinks(workspaceSlug, projectId, epicId).catch(() => []), epicService.listProgress(workspaceSlug, projectId).catch(() => ({})), diff --git a/apps/web/src/pages/IntakePage.tsx b/apps/web/src/pages/IntakePage.tsx index ccb51500..9b555aaf 100644 --- a/apps/web/src/pages/IntakePage.tsx +++ b/apps/web/src/pages/IntakePage.tsx @@ -112,7 +112,7 @@ export function IntakePage() { projectService.get(workspaceSlug, projectId), intakeService.list(workspaceSlug, projectId, 'pending'), intakeService.pendingCount(workspaceSlug, projectId), - issueService.list(workspaceSlug, projectId, { limit: 500 }), + issueService.listAll(workspaceSlug, projectId), ]) .then(([w, p, pending, count, issues]) => { if (cancelled) return; diff --git a/apps/web/src/pages/IssueDetailPage.tsx b/apps/web/src/pages/IssueDetailPage.tsx index 1785833f..e3926a9b 100644 --- a/apps/web/src/pages/IssueDetailPage.tsx +++ b/apps/web/src/pages/IssueDetailPage.tsx @@ -158,7 +158,7 @@ export function IssueDetailPage() { cycleService.list(workspaceSlug, projectId), moduleService.list(workspaceSlug, projectId), workspaceService.listMembers(workspaceSlug), - issueService.list(workspaceSlug, projectId, { limit: 250 }), + issueService.listAll(workspaceSlug, projectId), commentService.list(workspaceSlug, projectId, issueId), issueService .listActivities(workspaceSlug, projectId, issueId) @@ -1594,7 +1594,7 @@ export function IssueDetailPage() { .catch(() => {}); } const refreshedAll = await issueService - .list(workspaceSlug, project.id, { limit: 250 }) + .listAll(workspaceSlug, project.id) .catch(() => null); if (refreshedAll) setAllIssues(refreshedAll); setSubCreateOpen(false); diff --git a/apps/web/src/pages/ModuleDetailPage.tsx b/apps/web/src/pages/ModuleDetailPage.tsx index 630e8a00..060ebbc1 100644 --- a/apps/web/src/pages/ModuleDetailPage.tsx +++ b/apps/web/src/pages/ModuleDetailPage.tsx @@ -275,7 +275,7 @@ export function ModuleDetailPage() { const refetchIssues = () => { if (!workspaceSlug || !projectId || !resolvedModuleId) return; issueService - .list(workspaceSlug, projectId, { limit: 1000 }) + .listAll(workspaceSlug, projectId) .then((list) => { setIssues((list ?? []).filter((i) => i.module_ids?.includes(resolvedModuleId))); }) @@ -293,7 +293,7 @@ export function ModuleDetailPage() { workspaceService.getBySlug(workspaceSlug), projectService.get(workspaceSlug, projectId), moduleService.list(workspaceSlug, projectId), - issueService.list(workspaceSlug, projectId, { limit: 1000 }), + issueService.listAll(workspaceSlug, projectId), stateService.list(workspaceSlug, projectId), labelService.list(workspaceSlug, projectId), cycleService.list(workspaceSlug, projectId), diff --git a/apps/web/src/pages/ViewDetailPage.tsx b/apps/web/src/pages/ViewDetailPage.tsx index eff95bcf..69717e6d 100644 --- a/apps/web/src/pages/ViewDetailPage.tsx +++ b/apps/web/src/pages/ViewDetailPage.tsx @@ -197,7 +197,7 @@ export function ViewDetailPage() { const refetchIssues = () => { if (!workspaceSlug || !projectId) return; issueService - .list(workspaceSlug, projectId, { limit: 500 }) + .listAll(workspaceSlug, projectId) .then(setIssues) .catch(() => {}); }; @@ -279,7 +279,7 @@ export function ViewDetailPage() { workspaceService.getBySlug(workspaceSlug), projectService.get(workspaceSlug, projectId), projectService.list(workspaceSlug), - issueService.list(workspaceSlug, projectId, { limit: 500 }), + issueService.listAll(workspaceSlug, projectId), stateService.list(workspaceSlug, projectId), labelService.list(workspaceSlug, projectId), workspaceService.listMembers(workspaceSlug), diff --git a/apps/web/src/services/issueService.ts b/apps/web/src/services/issueService.ts index 78b8e686..e5ce35c5 100644 --- a/apps/web/src/services/issueService.ts +++ b/apps/web/src/services/issueService.ts @@ -63,6 +63,25 @@ export const issueService = { return data; }, + /** + * Fetch every issue for a project by paging through `list` in chunks of 100 + * (the server caps `limit` at 100). Use this instead of passing a large + * `limit`, which the server silently caps and truncates. + */ + async listAll(workspaceSlug: string, projectId: string): Promise { + const pageSize = 100; + const all: IssueApiResponse[] = []; + let offset = 0; + // Bound the loop so a misbehaving backend can't spin forever. + for (let page = 0; page < 500; page++) { + const batch = await this.list(workspaceSlug, projectId, { limit: pageSize, offset }); + all.push(...batch); + if (batch.length < pageSize) break; + offset += pageSize; + } + return all; + }, + async get(workspaceSlug: string, projectId: string, issueId: string): Promise { const { data } = await apiClient.get( `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/issues/${encodeURIComponent(issueId)}/`,