diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 04d2d423d99c..3e9ffc237bb3 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -194,6 +194,10 @@ layer("GiteaPullRequestApi", (it) => { }); expect(callAt(1).path).toContain("include_tracking=true"); expect(mockedRequest).toHaveBeenCalledTimes(2); + const api = yield* GiteaPullRequestApi.make; + yield* api.listPullRequests({ ...input, relationshipOnly: true, includeTracking: true }); + expect(callAt(2).path).not.toContain("include_tracking"); + expect(mockedRequest).toHaveBeenCalledTimes(3); }), ); it.effect("reconstructs auto-merge from the timeline when discovery is unavailable", () => @@ -1334,6 +1338,48 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect.each([1, 4])( + "marks inline comments incomplete when page %i has no pagination evidence", + (pageCount) => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response([ + { id: 21, body: "Review", state: "COMMENT", submitted_at: "2026-09-03T11:00:00Z" }, + ]), + ), + ); + for (let page = 1; page <= pageCount; page += 1) { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + Array.from({ length: 50 }, (_, index) => ({ + id: page * 50 + index, + body: `Comment ${index + 1}`, + path: "src/a.ts", + position: index + 1, + created_at: "2026-09-03T11:01:00Z", + })), + page === pageCount ? {} : { "x-total-count": "201" }, + ), + ), + ); + } + const api = yield* GiteaPullRequestApi.make; + const result = yield* api.listReviews({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + assert.isTrue(result.truncated); + expect(result.comments.filter((comment) => comment.kind === "review-comment")).toHaveLength( + 50 * pageCount, + ); + expect(mockedRequest).toHaveBeenCalledTimes(1 + pageCount); + }), + ); + it.effect("does not repeat an unpaginated native review-comment response at the page size", () => Effect.gen(function* () { mockedRequest @@ -1422,6 +1468,51 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect("shares the raw inline-comment budget across reviews", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response([ + { id: 21, body: "First review", submitted_at: "2026-09-03T11:00:00Z" }, + { id: 22, body: "Second review", submitted_at: "2026-09-03T12:00:00Z" }, + { id: 23, body: "Third review", submitted_at: "2026-09-03T13:00:00Z" }, + ]), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response([ + ...Array.from({ length: 199 }, (_, index) => ({ + id: index + 31, + body: `Comment ${index + 1}`, + path: "src/a.ts", + position: index + 1, + created_at: "2026-09-03T11:01:00Z", + })), + { id: "malformed" }, + ]), + ), + ); + const api = yield* GiteaPullRequestApi.make; + const result = yield* api.listReviews({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + expect( + result.comments.filter((comment) => comment.kind === "review").map((comment) => comment.id), + ).toEqual(["review:21", "review:22", "review:23"]); + expect(result.comments.filter((comment) => comment.kind === "review-comment")).toHaveLength( + 199, + ); + assert.isTrue(result.truncated); + expect(mockedRequest).toHaveBeenCalledTimes(2); + expect(callAt(1).path).toContain("/reviews/21/comments?"); + }), + ); + it.effect("follows pagination links when Gitea caps comment pages below the limit", () => Effect.gen(function* () { mockedRequest @@ -2249,7 +2340,7 @@ layer("GiteaPullRequestApi", (it) => { number: 7, }), ); - expect(callAt(2).path).toContain("page=2"); + expect(callAt(2).path).toBe("/repos/acme/web/issues/7/timeline?page=2&limit=1"); }), ); diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 19a71b8084fb..260b63d9cf8f 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -1079,13 +1079,20 @@ export const make = Effect.gen(function* () { nextLink(result.headers) !== null || totalCount(result.headers) !== null; const paginationNext = input.requirePaginationEvidence && !hasPaginationEvidence ? null : next; + // Native unpaginated endpoints can return more than the requested page size. Exactly + // one requested page is ambiguous when headers do not establish whether more rows exist. + const paginationUncertain = + input.requirePaginationEvidence === true && + !hasPaginationEvidence && + result.rows.length === PAGE_SIZE; if (result.rows.length > remaining || rows.length >= input.limit) { return { rows, - truncated: result.rows.length > remaining || paginationNext !== null, + truncated: + result.rows.length > remaining || paginationNext !== null || paginationUncertain, }; } - if (paginationNext === null) return { rows, truncated: false }; + if (paginationNext === null) return { rows, truncated: paginationUncertain }; path = paginationNext; } return { rows, truncated: true }; @@ -1109,7 +1116,7 @@ export const make = Effect.gen(function* () { sort: relationshipOnly ? "oldest" : "recentupdate", page, limit: PAGE_SIZE, - include_tracking: input.includeTracking === true ? "true" : undefined, + include_tracking: !relationshipOnly && input.includeTracking === true ? "true" : undefined, ...(input.involvement === "authored" ? { poster: input.viewer } : {}), }); let rowsSeen = 0; @@ -1368,6 +1375,7 @@ export const make = Effect.gen(function* () { const comments: Array = []; const threads: Array = []; let commentsTruncated = reviewsTruncated; + let remainingReviewCommentRows = PAGE_SIZE * CONVERSATION_PAGES; for (const row of reviewRows) { const review = decodeReview(row); if (Option.isNone(review)) continue; @@ -1384,6 +1392,10 @@ export const make = Effect.gen(function* () { reviewState: review.value.state?.toLowerCase().replaceAll("_", " ") ?? null, }); } + if (remainingReviewCommentRows === 0) { + commentsTruncated = true; + continue; + } const codeRows = yield* readUnknownSlice({ operation: "listReviewComments", ...input, @@ -1391,9 +1403,10 @@ export const make = Effect.gen(function* () { `${basePath(input.repository)}/pulls/${input.number}/reviews/${review.value.id}/comments`, { page: 1, limit: PAGE_SIZE }, ), - limit: PAGE_SIZE * CONVERSATION_PAGES, + limit: remainingReviewCommentRows, requirePaginationEvidence: true, }); + remainingReviewCommentRows -= codeRows.rows.length; commentsTruncated ||= codeRows.truncated; const grouped = new Map< string, diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 7c7ab00eb5e3..497237915c77 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -119,9 +119,9 @@ For Azure DevOps, use the host website to view diffs or change comments. Bitbuck reopening a declined pull request. Gitea supports PR tracking, comments, reviews, diffs, reviewer and label updates, merge methods, -branch updates, close/reopen, draft/ready changes, and auto-merge controls. Reactions, comment -editing, workflow approval, and revert PRs are not currently available in T3. Use your Gitea -website for those tasks. +branch updates, close/reopen, draft/ready changes, auto-merge controls, comment editing, and +reactions. Workflow approval and revert PRs are available when your Gitea server advertises +support for them. ## Troubleshooting