From 75687dc9b08e1c6fbcca2f0d3994a51c4a7109af Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Sat, 5 Sep 2026 20:36:46 +0000 Subject: [PATCH] fix: return page as object {page, next, total, hasMore, nextUrl} for routes.js compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit routes.js reads resp.page?.hasMore and resp.page?.nextUrl — the old HTTP client returned page as an object from parsePageInfo. The CLI pivot accidentally returned page as a number, making hasMore always undefined in routes.js. Restore the object shape in both gitea and gitlab clients. Update tests to assert the object shape. --- packages/web/server/lib/gitea/client.js | 26 +++++++++------- packages/web/server/lib/gitea/client.test.js | 30 ++++++++++++++----- packages/web/server/lib/gitlab/client.js | 21 +++++++++---- packages/web/server/lib/gitlab/client.test.js | 29 +++++++++++++----- 4 files changed, 73 insertions(+), 33 deletions(-) diff --git a/packages/web/server/lib/gitea/client.js b/packages/web/server/lib/gitea/client.js index 4fc8af08..7e0db751 100644 --- a/packages/web/server/lib/gitea/client.js +++ b/packages/web/server/lib/gitea/client.js @@ -131,24 +131,28 @@ async function teaApiCall(endpoint, { method = 'GET', body, raw, teaBin, token } data = bodyText; } - // Derive pagination from the Link header (H2 fix). + // Derive pagination info object matching the pre-pivot parsePageInfo shape. + // routes.js reads resp.page?.hasMore, resp.page?.nextUrl, etc. const { next } = parseLinkHeader(headers.link); const listIsArray = Array.isArray(data); - const hasMore = listIsArray ? next !== null : false; - // page is the caller's current page (extracted from the request context by the - // caller); for the client layer we return the page number derived from the - // Link header's "next" URL if present, otherwise 1. - let page = 1; - if (next) { + const totalRaw = headers['x-total-count']; + const total = totalRaw ? Number(totalRaw) : null; + let currentPage = null; + if (listIsArray && next) { const pageMatch = next.match(/[?&]page=(\d+)/); if (pageMatch) { - // next page exists — the *current* page is next - 1 (rough heuristic; - // callers that know the page can override). - page = Math.max(1, Number(pageMatch[1]) - 1); + currentPage = Math.max(1, Number(pageMatch[1]) - 1); } } + const pageInfo = { + page: currentPage, + next: next || null, + total: total !== null && Number.isFinite(total) ? total : null, + hasMore: listIsArray ? next !== null : false, + nextUrl: next || undefined, + }; - return { status, headers, data, page: listIsArray ? page : null, hasMore: listIsArray ? hasMore : undefined }; + return { status, headers, data, page: pageInfo }; } // ---- Rate-limit helpers (no-ops with CLI transport) ---- diff --git a/packages/web/server/lib/gitea/client.test.js b/packages/web/server/lib/gitea/client.test.js index 7fc2f570..ecf7c788 100644 --- a/packages/web/server/lib/gitea/client.test.js +++ b/packages/web/server/lib/gitea/client.test.js @@ -192,37 +192,51 @@ describe('createGiteaClient request basics', () => { }); describe('pagination', () => { - test('returns hasMore=true when Link header has rel="next"', async () => { + test('page is an object with hasMore=true when Link header has rel="next"', async () => { spawnMock = mockSpawn( cliOutput([{ id: 1 }], { headers: { 'Link': '; rel="next"', + 'X-Total-Count': '10', }, }), ); const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' }); const result = await client.issues('o', 'r', { limit: 1, page: 1 }); - expect(result.hasMore).toBe(true); - expect(result.page).toBe(1); + // page is the object shape that routes.js expects + expect(result.page).toEqual(expect.objectContaining({ + hasMore: true, + nextUrl: 'https://gitea.example.com/api/v1/repos/o/r/issues?limit=1&page=2', + next: 'https://gitea.example.com/api/v1/repos/o/r/issues?limit=1&page=2', + page: 1, + total: 10, + })); expect(Array.isArray(result.data)).toBe(true); }); - test('returns hasMore=false when no Link header', async () => { + test('page is an object with hasMore=false when no Link header', async () => { spawnMock = mockSpawn(cliOutput([{ id: 1 }])); const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' }); const result = await client.issues('o', 'r', { limit: 50 }); - expect(result.hasMore).toBe(false); - expect(result.page).toBe(1); + expect(result.page).toEqual(expect.objectContaining({ + hasMore: false, + next: null, + total: null, + })); }); - test('page is null for non-array responses', async () => { + test('page is an object for non-array responses too', async () => { spawnMock = mockSpawn(cliOutput({ id: 42 })); const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' }); const result = await client.user(); - expect(result.page).toBeNull(); + expect(result.page).toEqual(expect.objectContaining({ + hasMore: false, + next: null, + page: null, + })); }); test('does NOT pass --paginate flag to tea (C1 fix)', async () => { diff --git a/packages/web/server/lib/gitlab/client.js b/packages/web/server/lib/gitlab/client.js index bde4d674..5038a133 100644 --- a/packages/web/server/lib/gitlab/client.js +++ b/packages/web/server/lib/gitlab/client.js @@ -135,19 +135,28 @@ async function glabApiCall(endpoint, { method = 'GET', body, raw, glabBin, token data = bodyText; } - // Derive pagination from the Link header (H2 fix). + // Derive pagination info object matching the pre-pivot parsePageInfo shape. + // routes.js reads resp.page?.hasMore, resp.page?.nextUrl, etc. const { next } = parseLinkHeader(headers.link); const listIsArray = Array.isArray(data); - const hasMore = listIsArray ? next !== null : false; - let page = 1; - if (next) { + const totalRaw = headers['x-total'] || headers['x-total-count']; + const total = totalRaw ? Number(totalRaw) : null; + let currentPage = null; + if (listIsArray && next) { const pageMatch = next.match(/[?&]page=(\d+)/); if (pageMatch) { - page = Math.max(1, Number(pageMatch[1]) - 1); + currentPage = Math.max(1, Number(pageMatch[1]) - 1); } } + const pageInfo = { + page: currentPage, + next: next || null, + total: total !== null && Number.isFinite(total) ? total : null, + hasMore: listIsArray ? next !== null : false, + nextUrl: next || undefined, + }; - return { status, headers, data, page: listIsArray ? page : null, hasMore: listIsArray ? hasMore : undefined }; + return { status, headers, data, page: pageInfo }; } // ---- Rate-limit helpers (no-ops with CLI transport) ---- diff --git a/packages/web/server/lib/gitlab/client.test.js b/packages/web/server/lib/gitlab/client.test.js index 9ba2e1f1..c674111d 100644 --- a/packages/web/server/lib/gitlab/client.test.js +++ b/packages/web/server/lib/gitlab/client.test.js @@ -187,37 +187,50 @@ describe('createGitLabClient request basics', () => { }); describe('pagination', () => { - test('returns hasMore=true when Link header has rel="next"', async () => { + test('page is an object with hasMore=true when Link header has rel="next"', async () => { spawnMock = mockSpawn( cliOutput([{ id: 1 }], { headers: { 'Link': '; rel="next"', + 'X-Total': '10', }, }), ); const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' }); const result = await client.issues('g/p', { per_page: 1 }); - expect(result.hasMore).toBe(true); - expect(result.page).toBe(1); + expect(result.page).toEqual(expect.objectContaining({ + hasMore: true, + nextUrl: 'https://gitlab.com/api/v4/projects/g%2Fp/issues?page=2', + next: 'https://gitlab.com/api/v4/projects/g%2Fp/issues?page=2', + page: 1, + total: 10, + })); expect(Array.isArray(result.data)).toBe(true); }); - test('returns hasMore=false when no Link header', async () => { + test('page is an object with hasMore=false when no Link header', async () => { spawnMock = mockSpawn(cliOutput([{ id: 1 }])); const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' }); const result = await client.issues('g/p', { per_page: 50 }); - expect(result.hasMore).toBe(false); - expect(result.page).toBe(1); + expect(result.page).toEqual(expect.objectContaining({ + hasMore: false, + next: null, + total: null, + })); }); - test('page is null for non-array responses', async () => { + test('page is an object for non-array responses too', async () => { spawnMock = mockSpawn(cliOutput({ id: 42 })); const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' }); const result = await client.user(); - expect(result.page).toBeNull(); + expect(result.page).toEqual(expect.objectContaining({ + hasMore: false, + next: null, + page: null, + })); }); });