From 66816228603334ef91fac44635bb9264386d3618 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Mon, 7 Sep 2026 21:38:14 -0500 Subject: [PATCH 01/12] fix(gitea): preserve reviews when viewer teams are unavailable --- .../pullRequest/GiteaPullRequestApi.test.ts | 50 +++++++++++++++++++ .../src/pullRequest/GiteaPullRequestApi.ts | 4 ++ 2 files changed, 54 insertions(+) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 2cd4b76f1df58..6e42aac2d5db3 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -1946,6 +1946,56 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect.each([undefined, "fix"])( + "preserves individual reviewing matches when viewer teams are forbidden (query: %s)", + (query) => + Effect.gen(function* () { + const pullRequests = [ + rawPullRequest(7), + rawPullRequest(8, { + requested_reviewers: [], + requested_reviewers_teams: [{ id: 9, name: "maintainers" }], + }), + ]; + mockedRequest.mockImplementation((input) => { + if (input.path.startsWith("/user/teams?")) + return Effect.fail( + new GiteaApi.GiteaApiError({ + operation: "getViewerTeams", + reason: "failed", + detail: "Public-only tokens cannot access viewer teams.", + status: 403, + }), + ); + if (input.path.startsWith("/repos/acme/web/pulls?")) + return Effect.succeed(response(pullRequests)); + if (input.path.startsWith("/repos/acme/web/issues?")) + return Effect.succeed(response([{ number: 7 }, { number: 8 }])); + const pullRequest = pullRequests.find( + (pullRequest) => input.path === `/repos/acme/web/pulls/${pullRequest.number}`, + ); + if (pullRequest !== undefined) return Effect.succeed(response(pullRequest)); + return Effect.die(`unexpected request: ${input.path}`); + }); + const api = yield* GiteaPullRequestApi.make; + const page = yield* api.listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "open", + involvement: "reviewing", + viewer: "reviewer", + limit: 10, + ...(query === undefined ? {} : { query }), + }); + + expect(page.items.map((pullRequest) => pullRequest.number)).toEqual([7]); + expect(page.consumed).toBe(2); + expect( + mockedRequest.mock.calls.filter(([input]) => input.path.startsWith("/user/teams?")), + ).toHaveLength(1); + }), + ); + it.effect("includes pull requests requested from a viewer team in reviewing listings", () => Effect.gen(function* () { mockedRequest.mockImplementation((input) => { diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 2871ab9e15b03..f43a927a12fa5 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -1011,6 +1011,10 @@ export const make = Effect.gen(function* () { detail: "Gitea viewer team pagination exceeded the safe page limit.", }); }), + ).pipe( + // Public-only tokens can read pull requests while Gitea rejects their team lookup. + // Team membership enriches individual review requests and must not hide those matches. + Effect.orElseSucceed(() => new Set()), ), "1 minute", ); From 6068bea0ece4b0a62ec69aae93234541f1f3b815 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Mon, 7 Sep 2026 22:08:12 -0500 Subject: [PATCH 02/12] fix(server): normalize optional Gitea tracking flags --- apps/server/src/pullRequest/GiteaPullRequestApi.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 77b8546a3c1bf..64484c4a0959a 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -851,7 +851,7 @@ export const make = Effect.gen(function* () { viewer: input.viewer, page, limit: PAGE_SIZE, - includeTracking: input.includeTracking, + includeTracking: input.includeTracking === true, }); let rowsSeen = 0; let rowsSkipped = 0; @@ -886,7 +886,7 @@ export const make = Effect.gen(function* () { host: input.host, repository: input.repository, number, - includeTracking: input.includeTracking, + includeTracking: input.includeTracking === true, }); }, { concurrency: SEARCH_HYDRATION_CONCURRENCY }, From a714159e200528763a5ee01b37ad6d6a1de9375f Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Mon, 7 Sep 2026 22:08:23 -0500 Subject: [PATCH 03/12] fix(server): preserve Gitea revert capability types --- apps/server/src/pullRequest/GiteaPullRequestProvider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts index 1e1b2b7ecd927..b73727236602a 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts @@ -145,7 +145,7 @@ export const make = Effect.gen(function* () { giteaViewerPermissions({ canWrite: input.access.canWrite, workflowApprovalSupported: input.workflowApprovalSupported, - revertSupported: input.revertSupported, + revertSupported: input.revertSupported === true, ownsPullRequest: input.author !== undefined && input.author.toLowerCase() === input.viewer.toLowerCase(), updateMethods: input.access.updateMethods, @@ -195,7 +195,7 @@ export const make = Effect.gen(function* () { api .getWorkflowApprovals(input) .pipe(Effect.orElseSucceed(() => ({ supported: false, runs: [] }))), - api.getFeatures().pipe(Effect.orElseSucceed(() => [])), + api.getFeatures().pipe(Effect.orElseSucceed((): ReadonlyArray => [])), ], { concurrency: 4 }, ).pipe( From d3f4d68aa7be6ebfee9689096a5bf12eb24f234d Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Mon, 7 Sep 2026 21:38:14 -0500 Subject: [PATCH 04/12] fix(gitea): preserve reviews when viewer teams are unavailable --- .../pullRequest/GiteaPullRequestApi.test.ts | 50 +++++++++++++++++++ .../src/pullRequest/GiteaPullRequestApi.ts | 4 ++ 2 files changed, 54 insertions(+) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 49540f2078ad7..54daa041f2f31 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -2432,6 +2432,56 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect.each([undefined, "fix"])( + "preserves individual reviewing matches when viewer teams are forbidden (query: %s)", + (query) => + Effect.gen(function* () { + const pullRequests = [ + rawPullRequest(7), + rawPullRequest(8, { + requested_reviewers: [], + requested_reviewers_teams: [{ id: 9, name: "maintainers" }], + }), + ]; + mockedRequest.mockImplementation((input) => { + if (input.path.startsWith("/user/teams?")) + return Effect.fail( + new GiteaApi.GiteaApiError({ + operation: "getViewerTeams", + reason: "failed", + detail: "Public-only tokens cannot access viewer teams.", + status: 403, + }), + ); + if (input.path.startsWith("/repos/acme/web/pulls?")) + return Effect.succeed(response(pullRequests)); + if (input.path.startsWith("/repos/acme/web/issues?")) + return Effect.succeed(response([{ number: 7 }, { number: 8 }])); + const pullRequest = pullRequests.find( + (pullRequest) => input.path === `/repos/acme/web/pulls/${pullRequest.number}`, + ); + if (pullRequest !== undefined) return Effect.succeed(response(pullRequest)); + return Effect.die(`unexpected request: ${input.path}`); + }); + const api = yield* GiteaPullRequestApi.make; + const page = yield* api.listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "open", + involvement: "reviewing", + viewer: "reviewer", + limit: 10, + ...(query === undefined ? {} : { query }), + }); + + expect(page.items.map((pullRequest) => pullRequest.number)).toEqual([7]); + expect(page.consumed).toBe(2); + expect( + mockedRequest.mock.calls.filter(([input]) => input.path.startsWith("/user/teams?")), + ).toHaveLength(1); + }), + ); + it.effect("includes pull requests requested from a viewer team in reviewing listings", () => Effect.gen(function* () { mockedRequest.mockImplementation((input) => { diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 260b63d9cf8f2..b8d13bd15b216 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -1041,6 +1041,10 @@ export const make = Effect.gen(function* () { detail: "Gitea viewer team pagination exceeded the safe page limit.", }); }), + ).pipe( + // Public-only tokens can read pull requests while Gitea rejects their team lookup. + // Team membership enriches individual review requests and must not hide those matches. + Effect.orElseSucceed(() => new Set()), ), "1 minute", ); From 03271ac05d5a153c99436c767758212f4fe1d1b8 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Mon, 7 Sep 2026 22:16:05 -0500 Subject: [PATCH 05/12] fix(server): omit tracking from Gitea dependency reads --- apps/server/src/pullRequest/GiteaPullRequestApi.test.ts | 2 ++ apps/server/src/pullRequest/GiteaPullRequestApi.ts | 2 +- apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts | 2 +- apps/server/src/pullRequest/GiteaPullRequestProvider.ts | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index dfc6e58af8793..3da33eeab18a9 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -423,12 +423,14 @@ layer("GiteaPullRequestApi", (it) => { viewer: "", limit: 200, relationshipOnly: true, + includeTracking: true, }); expect(page.items.map((item) => item.number)).toEqual([1]); assert.strictEqual(page.consumed, 2); assert.isTrue(page.truncated); assert.strictEqual(mockedRequest.mock.calls.length, 1); + expect(callAt(0).path).not.toContain("include_tracking"); }), ); diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index db7b05f945745..d2706a42826f1 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -1045,7 +1045,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; diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts index f65ba3c56fcfb..068c2629a449e 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts @@ -95,7 +95,7 @@ describe("GiteaPullRequestProvider", () => { switch (input.path) { case "/settings/api": return Effect.succeed(response({ features: [] })); - case "/repos/acme/web/pulls/7": + case "/repos/acme/web/pulls/7?include_tracking=true": return Effect.succeed(response(rawPullRequest())); case "/repos/acme/web": return Effect.succeed(response({ permissions: { push: true } })); diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts index e42f373527577..32abe2838a12b 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts @@ -168,7 +168,7 @@ export const make = Effect.gen(function* () { involvement: input.involvement, viewer: input.viewer, limit: input.limit, - includeTracking: true, + includeTracking: input.relationshipOnly !== true, ...(input.query === undefined ? {} : { query: input.query }), ...(input.cursor === undefined ? {} : { cursor: input.cursor }), ...(input.relationshipOnly === undefined From 6d81c319c16f9127f1f3d678fb7086951dae58cf Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Mon, 7 Sep 2026 22:17:41 -0500 Subject: [PATCH 06/12] fix(client): retain scoped dependency refresh after stack integration --- packages/client-runtime/src/state/pullRequests.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 48434d1c4ca7a..c574f03c4a74d 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -213,13 +213,6 @@ export function createPullRequestEnvironmentAtoms( refreshTrigger: ({ environmentId, input }) => refreshes({ environmentId, input: { kind: "repository", reference: input } }), }), - /** One bounded repository relationship read for the open PR panel, never for list rows. */ - dependencyContext: createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:pull-requests:dependency-context", - tag: WS_METHODS.pullRequestsDependencyContext, - staleTimeMs: 30_000, - refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), - }), activity, threadComments: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:thread-comments", From ca97a942a068039ad27cd7ecb080deed93dc958c Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Mon, 7 Sep 2026 22:18:08 -0500 Subject: [PATCH 07/12] fix(server): restore Gitea team reviewer reads --- apps/server/src/pullRequest/GiteaPullRequestApi.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 5fb549fea9641..2bbba13f2a54c 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -2128,12 +2128,13 @@ export const make = Effect.gen(function* () { }), limit: PAGE_SIZE, }), - readUnknownArray({ + readUnknownPage({ operation: "listTeamReviewerCandidates", ...input, path: `${basePath(input.repository)}/teams`, }).pipe( - Effect.catchTag("GiteaPullRequestApiError", (error: GiteaPullRequestApiError) => + Effect.map((page) => page.rows), + Effect.catch((error) => isGiteaApiError(error.cause) && error.cause.status === 405 ? Effect.succeed([]) : Effect.fail(error), From 192f07192c59dad9a1ba552c56dce3fb52f7163a Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Mon, 7 Sep 2026 22:18:53 -0500 Subject: [PATCH 08/12] fix(server): remove duplicate Gitea pagination option --- apps/server/src/pullRequest/GiteaPullRequestApi.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 7f25eabc1a615..2bbba13f2a54c 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -1052,7 +1052,6 @@ export const make = Effect.gen(function* () { limit: number; nullAsEmpty?: boolean; requirePaginationEvidence?: boolean; - nullAsEmpty?: boolean; }) { const rows: Array = []; let path = input.path; From e0112363be2a3023c9cd7f3cd6fb2ff18be7a721 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Mon, 7 Sep 2026 22:19:48 -0500 Subject: [PATCH 09/12] test(pull-requests): retain repository evidence in tracking fixture --- apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts index 068c2629a449e..3e7a87c9b5ba4 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts @@ -21,9 +21,14 @@ const trackedPullRequest: GiteaPullRequest = { url: "https://forge.example.test/acme/web/pulls/7", author: null, headBranch: "feature", + relationshipHeadBranch: "feature", + headBranchAvailable: true, + headRepositoryId: 1, headSha: "head-sha", headRepositoryNameWithOwner: "acme/web", baseBranch: "main", + baseRepositoryNameWithOwner: "acme/web", + baseRepositoryId: 1, baseSha: "base-sha", mergeBaseSha: "base-sha", state: "open", From 7f45a192824419d68f270895a700c2f2c2aaacba Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Mon, 7 Sep 2026 23:11:54 -0500 Subject: [PATCH 10/12] fix(gitea): report partial reviewing coverage when teams fail --- .../pullRequest/GiteaPullRequestApi.test.ts | 96 ++++++++++--------- .../src/pullRequest/GiteaPullRequestApi.ts | 41 ++++++-- .../GiteaPullRequestProvider.test.ts | 54 +++++++++++ .../pullRequest/GiteaPullRequestProvider.ts | 3 + .../src/pullRequest/PullRequestProvider.ts | 2 + .../pullRequest/PullRequestService.test.ts | 35 +++++++ .../src/pullRequest/PullRequestService.ts | 12 ++- .../pullRequest/pullRequestList.logic.test.ts | 26 +++++ .../pullRequest/pullRequestList.logic.ts | 6 +- apps/web/src/routes/_chat.pull-requests.tsx | 53 ++++++++-- packages/contracts/src/pullRequest.ts | 4 +- 11 files changed, 262 insertions(+), 70 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 9136e5e6c538e..b756070a0adb5 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -2438,54 +2438,58 @@ layer("GiteaPullRequestApi", (it) => { }), ); - it.effect.each([undefined, "fix"])( - "preserves individual reviewing matches when viewer teams are forbidden (query: %s)", - (query) => - Effect.gen(function* () { - const pullRequests = [ - rawPullRequest(7), - rawPullRequest(8, { - requested_reviewers: [], - requested_reviewers_teams: [{ id: 9, name: "maintainers" }], - }), - ]; - mockedRequest.mockImplementation((input) => { - if (input.path.startsWith("/user/teams?")) - return Effect.fail( - new GiteaApi.GiteaApiError({ - operation: "getViewerTeams", - reason: "failed", - detail: "Public-only tokens cannot access viewer teams.", - status: 403, - }), - ); - if (input.path.startsWith("/repos/acme/web/pulls?")) - return Effect.succeed(response(pullRequests)); - if (input.path.startsWith("/repos/acme/web/issues?")) - return Effect.succeed(response([{ number: 7 }, { number: 8 }])); - const pullRequest = pullRequests.find( - (pullRequest) => input.path === `/repos/acme/web/pulls/${pullRequest.number}`, + it.effect.each( + [undefined, "fix"].flatMap((query) => [403, 503].map((status) => ({ query, status }))), + )("preserves individual reviewing matches with partial team coverage (%j)", ({ query, status }) => + Effect.gen(function* () { + const pullRequests = [ + rawPullRequest(7), + rawPullRequest(8, { + requested_reviewers: [], + requested_reviewers_teams: [{ id: 9, name: "maintainers" }], + }), + ]; + mockedRequest.mockImplementation((input) => { + if (input.path.startsWith("/user/teams?")) + return Effect.fail( + new GiteaApi.GiteaApiError({ + operation: "getViewerTeams", + reason: "failed", + detail: "Viewer teams could not be read.", + status, + }), ); - if (pullRequest !== undefined) return Effect.succeed(response(pullRequest)); - return Effect.die(`unexpected request: ${input.path}`); - }); - const api = yield* GiteaPullRequestApi.make; - const page = yield* api.listPullRequests({ - host: "forge.example.test", - repository: "acme/web", - state: "open", - involvement: "reviewing", - viewer: "reviewer", - limit: 10, - ...(query === undefined ? {} : { query }), - }); + if (input.path.startsWith("/repos/acme/web/pulls?")) + return Effect.succeed(response(pullRequests)); + if (input.path.startsWith("/repos/acme/web/issues?")) + return Effect.succeed(response([{ number: 7 }, { number: 8 }])); + const pullRequest = pullRequests.find( + (pullRequest) => input.path === `/repos/acme/web/pulls/${pullRequest.number}`, + ); + if (pullRequest !== undefined) return Effect.succeed(response(pullRequest)); + return Effect.die(`unexpected request: ${input.path}`); + }); + const api = yield* GiteaPullRequestApi.make; + const page = yield* api.listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "open", + involvement: "reviewing", + viewer: "reviewer", + limit: 10, + ...(query === undefined ? {} : { query }), + }); - expect(page.items.map((pullRequest) => pullRequest.number)).toEqual([7]); - expect(page.consumed).toBe(2); - expect( - mockedRequest.mock.calls.filter(([input]) => input.path.startsWith("/user/teams?")), - ).toHaveLength(1); - }), + expect(page.items.map((pullRequest) => pullRequest.number)).toEqual([7]); + expect(page.consumed).toBe(2); + expect(page.truncated).toBe(false); + expect(page.coverageWarning).toBe( + "Some team review requests may be missing because Gitea team membership could not be read.", + ); + expect( + mockedRequest.mock.calls.filter(([input]) => input.path.startsWith("/user/teams?")), + ).toHaveLength(1); + }), ); it.effect("includes pull requests requested from a viewer team in reviewing listings", () => diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 2bbba13f2a54c..88bccd4ee0cd2 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -520,6 +520,7 @@ export class GiteaPullRequestApi extends Context.Service< items: ReadonlyArray; truncated: boolean; consumed: number; + coverageWarning?: string; }, GiteaPullRequestApiError >; @@ -910,8 +911,16 @@ export const make = Effect.gen(function* () { let rowsSkipped = 0; let consumed = 0; const collected: Array = []; - const viewerTeamIDs = - input.involvement === "reviewing" ? yield* getViewerTeamIDs : new Set(); + const viewerTeams = + input.involvement === "reviewing" + ? yield* getViewerTeams + : { ids: new Set(), available: true }; + const coverage = viewerTeams.available + ? {} + : { + coverageWarning: + "Some team review requests may be missing because Gitea team membership could not be read.", + }; while (page <= MAX_PAGINATION_PAGES) { const result = yield* readUnknownPage({ @@ -956,7 +965,7 @@ export const make = Effect.gen(function* () { input.state, input.involvement, input.viewer, - viewerTeamIDs, + viewerTeams.ids, ) ) continue; @@ -966,6 +975,7 @@ export const make = Effect.gen(function* () { // search that cannot fill its requested slice reaches the bounded failure below. return { items: collected, + ...coverage, truncated: index < pageRows.length - 1 || next !== null, consumed, }; @@ -984,6 +994,7 @@ export const make = Effect.gen(function* () { } return { items: collected, + ...coverage, truncated: false, consumed, }; @@ -1007,7 +1018,7 @@ export const make = Effect.gen(function* () { "1 minute", ); - const getViewerTeamIDs = yield* Effect.cachedWithTTL( + const getViewerTeams = yield* Effect.cachedWithTTL( Effect.suspend(() => Effect.gen(function* () { const teamIDs = new Set(); @@ -1027,7 +1038,7 @@ export const make = Effect.gen(function* () { rowsSeen, headers: response.headers, }); - if (next === null) return teamIDs; + if (next === null) return { ids: teamIDs, available: true }; path = next; } return yield* new GiteaPullRequestApiError({ @@ -1039,7 +1050,7 @@ export const make = Effect.gen(function* () { ).pipe( // Public-only tokens can read pull requests while Gitea rejects their team lookup. // Team membership enriches individual review requests and must not hide those matches. - Effect.orElseSucceed(() => new Set()), + Effect.orElseSucceed(() => ({ ids: new Set(), available: false })), ), "1 minute", ); @@ -1127,8 +1138,16 @@ export const make = Effect.gen(function* () { const repositoryIdsByName = new Map(); const expectedRepository = input.repository.trim().toLowerCase(); const collected: Array = []; - const viewerTeamIDs = - input.involvement === "reviewing" ? yield* getViewerTeamIDs : new Set(); + const viewerTeams = + input.involvement === "reviewing" + ? yield* getViewerTeams + : { ids: new Set(), available: true }; + const coverage = viewerTeams.available + ? {} + : { + coverageWarning: + "Some team review requests may be missing because Gitea team membership could not be read.", + }; const maxPages = relationshipOnly ? DEPENDENCY_PAGINATION_PAGES : MAX_PAGINATION_PAGES; let pagesRead = 0; let prefetchedPage: UnknownPage | null = null; @@ -1224,7 +1243,9 @@ export const make = Effect.gen(function* () { else if (observed !== id) relationshipEvidenceIncomplete = true; } } - if (!matchesPullRequest(pr, input.state, input.involvement, input.viewer, viewerTeamIDs)) { + if ( + !matchesPullRequest(pr, input.state, input.involvement, input.viewer, viewerTeams.ids) + ) { relationshipEvidenceIncomplete = relationshipOnly || relationshipEvidenceIncomplete; continue; } @@ -1239,6 +1260,7 @@ export const make = Effect.gen(function* () { } return { items: collected, + ...coverage, truncated: relationshipEvidenceIncomplete || index < pageRows.length - 1 || next !== null, consumed, @@ -1258,6 +1280,7 @@ export const make = Effect.gen(function* () { } return { items: collected, + ...coverage, truncated: relationshipOnly && (relationshipEvidenceIncomplete || next !== null), consumed, }; diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts index 1478450e33da2..1525ce3363ed0 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts @@ -149,6 +149,60 @@ describe("GiteaPullRequestProvider", () => { expect("autoMergeEnabled" in detail).toBe(false); }), ); + it.effect.each([undefined, "fix"])( + "retains Reviewing coverage warnings in provider listings (%s)", + (query) => + Effect.gen(function* () { + const pull = { ...rawPullRequest(), requested_reviewers: [{ login: "reader" }] }; + const request = vi.fn((input) => { + if (input.path.startsWith("/user/teams?")) + return Effect.fail( + new GiteaApi.GiteaApiError({ + operation: "getViewerTeams", + reason: "failed", + status: 403, + detail: "Teams unavailable", + }), + ); + if (input.path.startsWith("/repos/acme/web/pulls?")) + return Effect.succeed(response([pull])); + if (input.path.startsWith("/repos/acme/web/issues?")) + return Effect.succeed(response([{ number: 7 }])); + if (input.path.startsWith("/repos/acme/web/pulls/7")) + return Effect.succeed(response(pull)); + return Effect.die(`Unexpected Gitea request: ${input.path}`); + }); + const apiLayer = GiteaPullRequestApi.layer.pipe( + Layer.provide( + Layer.succeed( + GiteaApi.GiteaApi, + GiteaApi.GiteaApi.of({ + baseUrl: Option.some("https://forge.example.test/gitea"), + sshHosts: [], + request, + probeAuth: Effect.die("not used"), + }), + ), + ), + ); + const provider = yield* makeGiteaPullRequestProvider.pipe(Effect.provide(apiLayer)); + const page = yield* provider.listChangeRequests({ + cwd: "/repo", + host: "forge.example.test", + repository: "acme/web", + state: "open", + involvement: "reviewing", + viewer: "reader", + limit: 10, + ...(query === undefined ? {} : { query }), + }); + expect(page.items.map((item) => item.number)).toEqual([7]); + expect(page.coverageWarning).toBe( + "Some team review requests may be missing because Gitea team membership could not be read.", + ); + expect(page.truncated).toBe(false); + }), + ); }); describe("giteaViewerPermissions", () => { diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts index b4ee21a13ef94..4011309bbe322 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts @@ -186,6 +186,9 @@ export const make = Effect.gen(function* () { items: page.items.map((pullRequest) => giteaToChangeRequest(pullRequest, input.relationshipOnly === true), ), + ...(page.coverageWarning === undefined + ? {} + : { coverageWarning: page.coverageWarning }), truncated: page.truncated, cursorAdvance: page.consumed, continues: true, diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 28f059c53cff2..436005835847c 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -104,6 +104,8 @@ export interface ProviderChangeRequestSummary { } export interface ProviderChangeRequestPage { + /** Optional enrichment failed; pagination still describes only the rows the host returned. */ + readonly coverageWarning?: string; readonly items: ReadonlyArray; /** True when the host has more rows than the page size asked for. */ readonly truncated: boolean; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 53cd8676b640b..4768bdb638f97 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -935,6 +935,41 @@ it.effect("keeps healthy repositories when one of them cannot be read", () => }), ); +it.effect( + "returns partial listing coverage with readable rows and no fabricated continuation", + () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(1, "2026-07-02T00:00:00Z")], + coverageWarning: "Team review requests may be missing.", + truncated: false, + continues: true, + }), + }), + ], + }); + const result = yield* service.list({ state: "open", involvement: "reviewing" }); + assert.strictEqual(result.entries.length, 1); + assert.deepStrictEqual(result.errors, [ + { + projectId: "p1" as ProjectId, + projectTitle: "web", + message: "Team review requests may be missing.", + partial: true, + }, + ]); + assert.isFalse(result.truncated); + assert.deepStrictEqual(result.nextCursors, {}); + }), +); + it.effect("tries another workspace on the same host for the viewer", () => Effect.gen(function* () { const service = yield* makeService({ diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 60ac6ed6d0e58..1db91b3ac1a2e 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1060,7 +1060,17 @@ export const make = Effect.gen(function* () { entries: items .filter((item) => matchesRowFilters(item, input.filters, viewer)) .map((item) => toEntry({ project, item, viewer })), - errors: [], + errors: + page.coverageWarning === undefined + ? [] + : [ + { + projectId: project.project.id, + projectTitle: project.project.title, + message: page.coverageWarning, + partial: true, + }, + ], truncated: page.truncated, nextCursor: page.continues && page.truncated diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index b3fc1da8d0a3d..470c6448c1eae 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -947,6 +947,32 @@ describe("the list snapshot across a reload", () => { expect(snapshot?.data.nextCursors).toEqual({}); }); + it("retains partial coverage while hydrating the rows that were read", () => { + const storage = makeStorage(); + const warning = { + projectId: "project-1" as ProjectId, + environmentId: "env-1" as EnvironmentId, + projectTitle: "Web", + message: "Team review requests may be missing.", + partial: true, + }; + writePullRequestListSnapshot(storage, "env-1", { + scope: "s", + data: { + entries: [entry({ number: 1 })], + viewers: {}, + providers: [], + errors: [warning], + truncated: false, + truncatedEnvironments: [], + nextCursors: {}, + }, + }); + const snapshot = readPullRequestListSnapshot(storage, "env-1"); + expect(snapshot?.data.entries.map((item) => item.number)).toEqual([1]); + expect(snapshot?.data.errors).toEqual([warning]); + }); + it("answers nothing for another environment", () => { const storage = makeStorage(); writePullRequestListSnapshot(storage, "env-1", { scope: "s", data }); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index af1bd6ab4fbe7..efa610ad68f2c 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -845,9 +845,9 @@ export function writePullRequestListSnapshot( data: { ...snapshot.data, entries: snapshot.data.entries.slice(0, SNAPSHOT_MAX_ENTRIES), - // A failure is never cached and yesterday's is not this morning's; a cursor names a - // position in a listing the host has long since forgotten. - errors: [], + // Coverage warnings belong to the retained rows. Drop transient failures and cursors + // whose positions may no longer exist on the host. + errors: snapshot.data.errors.filter((error) => error.partial), nextCursors: {}, // Where a listing stopped is as stale as the cursor that named it. truncatedEnvironments: [], diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index bcc22daa4a9d2..14371a5584b5f 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -925,6 +925,17 @@ function PullRequestsRouteView() { current.scope === scopeKey ? current.partitions : undefined; + const partitionWarnings = partitionsWanted + ? (reviewingQuery.data?.errors.filter((error) => error.partial) ?? + (current?.scope === scopeKey ? current.data.errors.filter((error) => error.partial) : [])) + : []; + const retainedErrors = new Map( + [...data.errors, ...partitionWarnings].map( + (error) => + [JSON.stringify([error.environmentId, error.projectId, error.message]), error] as const, + ), + ); + const retainedData = { ...data, errors: [...retainedErrors.values()] }; // A search's answer is the search's, not the workspace's, so only unsearched lists // persist. Written here where the held partitions are in reach, so a feed settling // ahead of them cannot overwrite a stored snapshot that already had both groups. @@ -942,7 +953,7 @@ function PullRequestsRouteView() { { scope: scopeKey, data: { - ...data, + ...retainedData, entries: accumulatedEntries, viewers: baselineQuery.data?.viewers ?? data.viewers, providers: baselineQuery.data?.providers ?? data.providers, @@ -955,7 +966,7 @@ function PullRequestsRouteView() { environmentKey, scope: scopeKey, query: sentQuery, - data, + data: retainedData, ...(partitions === undefined ? {} : { partitions }), }; }); @@ -1125,7 +1136,17 @@ function PullRequestsRouteView() { ); const viewers = baselineQuery.data?.viewers ?? listData?.viewers ?? EMPTY_VIEWERS; - const listErrors = baselineQuery.data?.errors ?? listData?.errors ?? []; + const listErrors = listData?.errors ?? baselineQuery.data?.errors ?? []; + const coverageWarnings = [ + ...new Set([ + ...listErrors.filter((error) => error.partial).map((error) => error.message), + ...(partitionsWanted + ? (reviewingQuery.data?.errors ?? (loaded?.scope === scopeKey ? loaded.data.errors : [])) + .filter((error) => error.partial) + .map((error) => error.message) + : []), + ]), + ]; const facets = useMemo( () => collectPullRequestListFacets( @@ -1554,13 +1575,15 @@ function PullRequestsRouteView() { const unavailableProjects = useMemo( () => new Map( - listErrors.map( - (error) => - [ - pullRequestProjectKey({ id: error.projectId, environmentId: error.environmentId }), - error.message, - ] as const, - ), + listErrors + .filter((error) => !error.partial) + .map( + (error) => + [ + pullRequestProjectKey({ id: error.projectId, environmentId: error.environmentId }), + error.message, + ] as const, + ), ), [listErrors], ); @@ -1624,6 +1647,16 @@ function PullRequestsRouteView() { showingCarried && listQuery.isPending && entries.length === 0 && typedQuery.length === 0; const listBody = ( <> + {coverageWarnings.length > 0 ? ( +
+ {coverageWarnings.map((message) => ( +

{message}

+ ))} +
+ ) : null} {!capabilityKnown ? ( ) : !pullRequestsSupported ? ( diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 449c2a4c18249..6f94a0639dff8 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -625,8 +625,10 @@ export const PullRequestProviderSummary = Schema.Struct({ }); export type PullRequestProviderSummary = typeof PullRequestProviderSummary.Type; -/** One project whose repository could not be read; healthy projects still return entries. */ +/** A project whose repository could not be read fully; available entries are still returned. */ export const PullRequestListProjectError = Schema.Struct({ + /** Partial coverage preserves usable rows and does not disable the project filter. */ + partial: Schema.optional(Schema.Boolean), projectId: ProjectId, projectTitle: TrimmedNonEmptyString, message: TrimmedNonEmptyString, From c5f5e62d094faef54c42dd7d43b860aa2221c72e Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Mon, 7 Sep 2026 23:14:12 -0500 Subject: [PATCH 11/12] test(gitea): retain remaining inline review budget coverage --- .../pullRequest/GiteaPullRequestApi.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index d6a284096fadb..1389f20e7dc05 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -1569,6 +1569,49 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect("limits later reviews to the remaining shared inline-comment budget", () => + 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" }, + ]), + ), + ); + for (const [offset, length] of [ + [0, 150], + [150, 100], + ] as const) { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + Array.from({ length }, (_, index) => ({ + id: offset + index + 31, + body: "Comment", + path: "src/a.ts", + position: 1, + created_at: "2026-09-03T11:01:00Z", + })), + ), + ), + ); + } + const api = yield* GiteaPullRequestApi.make; + const result = yield* api.listReviews({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + const inlineComments = result.comments.filter((comment) => comment.kind === "review-comment"); + expect(inlineComments).toHaveLength(200); + expect(inlineComments.at(-1)?.id).toBe("review-comment:230"); + expect(result.truncated).toBe(true); + expect(mockedRequest).toHaveBeenCalledTimes(3); + }), + ); + it.effect("follows pagination links when Gitea caps comment pages below the limit", () => Effect.gen(function* () { mockedRequest From bf4b4ec1fbb7917896ea0f8110c8fd19daaec5c0 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Mon, 7 Sep 2026 23:14:55 -0500 Subject: [PATCH 12/12] fix(pull-requests): retain coverage warnings across continuation pages --- .../pullRequest/pullRequestList.logic.test.ts | 53 +++++++++++++++++++ .../pullRequest/pullRequestList.logic.ts | 13 +++++ apps/web/src/routes/_chat.pull-requests.tsx | 19 ++++--- 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 470c6448c1eae..f66082dc14d8a 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -5,6 +5,7 @@ import { filterPullRequestsByInvolvement, findScopedProject, mergePullRequestLists, + mergePullRequestListErrors, pullRequestEntryKey, pullRequestEnvironmentSetKey, groupPullRequestsByInvolvement, @@ -1171,6 +1172,58 @@ describe("merging the environments' own listings", () => { expect(merged?.errors).toEqual([{ ...error, environmentId: ENV_1 }]); }); + it("keeps exhausted repositories' coverage while another environment continues and after reload", () => { + const warning = { + projectId: "project-1" as ProjectId, + projectTitle: "Web", + message: "Team review requests may be missing.", + partial: true, + }; + const failure = { + projectId: "project-2" as ProjectId, + projectTitle: "Unavailable", + message: "Repository could not be read.", + }; + const baseline = mergePullRequestLists([ + [ENV_1, answer({ entries: [entry({ number: 1 })], errors: [warning, failure] })], + [ + ENV_2, + answer({ + entries: [entry({ number: 2 })], + truncated: true, + nextCursors: { "github.com/acme/web": "next" }, + }), + ], + ])!; + const continuation = mergePullRequestLists([ + [ENV_2, answer({ entries: [entry({ number: 3 })] })], + ])!; + const errors = mergePullRequestListErrors( + baseline.errors, + continuation.errors, + baseline.errors, + ); + expect(errors).toEqual([ + { ...warning, environmentId: ENV_1 }, + { ...failure, environmentId: ENV_1 }, + ]); + + let stored: string | null = null; + const storage = { + getItem: () => stored, + setItem: (_key: string, value: string) => { + stored = value; + }, + }; + writePullRequestListSnapshot(storage, "both", { + scope: "reviewing", + data: { ...baseline, entries: [...baseline.entries, ...continuation.entries], errors }, + }); + const snapshot = readPullRequestListSnapshot(storage, "both"); + expect(snapshot?.data.entries.map((item) => item.number)).toEqual([1, 2, 3]); + expect(snapshot?.data.errors).toEqual([{ ...warning, environmentId: ENV_1 }]); + }); + it("folds a host reached from two environments into one switcher row", () => { const merged = mergePullRequestLists([ [ENV_1, answer()], diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index efa610ad68f2c..08d4cc9ae474d 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -742,6 +742,19 @@ export function mergePullRequestLists( }; } +/** A continuation omits exhausted repositories; their retained rows still carry coverage errors. */ +export function mergePullRequestListErrors( + ...pages: ReadonlyArray | undefined> +): ReadonlyArray { + const errors = new Map(); + for (const page of pages) { + for (const error of page ?? []) { + errors.set(JSON.stringify([error.environmentId, error.projectId, error.message]), error); + } + } + return [...errors.values()]; +} + /** One page is what the list itself starts with, and all a cold start needs to look warm. */ const SNAPSHOT_MAX_ENTRIES = 99; diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index eaaa9a6f24cdf..64e85dfdf3035 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -53,6 +53,7 @@ import { parsePullRequestQuery, narrowPullRequestsToFilters, mergePullRequestDiffStats, + mergePullRequestListErrors, partitionPullRequestsWithPriority, pullRequestDiffStatKey, pullRequestEntryKey, @@ -929,13 +930,14 @@ function PullRequestsRouteView() { ? (reviewingQuery.data?.errors.filter((error) => error.partial) ?? (current?.scope === scopeKey ? current.data.errors.filter((error) => error.partial) : [])) : []; - const retainedErrors = new Map( - [...data.errors, ...partitionWarnings].map( - (error) => - [JSON.stringify([error.environmentId, error.projectId, error.message]), error] as const, + const retainedData = { + ...data, + errors: mergePullRequestListErrors( + baselineQuery.data?.errors, + data.errors, + partitionWarnings, ), - ); - const retainedData = { ...data, errors: [...retainedErrors.values()] }; + }; // A search's answer is the search's, not the workspace's, so only unsearched lists // persist. Written here where the held partitions are in reach, so a feed settling // ahead of them cannot overwrite a stored snapshot that already had both groups. @@ -1136,7 +1138,10 @@ function PullRequestsRouteView() { ); const viewers = baselineQuery.data?.viewers ?? listData?.viewers ?? EMPTY_VIEWERS; - const listErrors = listData?.errors ?? baselineQuery.data?.errors ?? []; + const listErrors = useMemo( + () => mergePullRequestListErrors(baselineQuery.data?.errors, listData?.errors), + [baselineQuery.data?.errors, listData?.errors], + ); const coverageWarnings = [ ...new Set([ ...listErrors.filter((error) => error.partial).map((error) => error.message),