From 97480404186ba0977b0fd3e151d7e0d70db381af Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 01:16:53 -0500 Subject: [PATCH 1/6] fix(pull-requests): decode native Gitea reactions --- .../src/pullRequest/GiteaConversation.test.ts | 22 +++++++++++++++---- .../src/pullRequest/GiteaConversation.ts | 4 ++-- .../pullRequest/GiteaPullRequestApi.test.ts | 6 ++--- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaConversation.test.ts b/apps/server/src/pullRequest/GiteaConversation.test.ts index b9aad258c90e..574de0b6d759 100644 --- a/apps/server/src/pullRequest/GiteaConversation.test.ts +++ b/apps/server/src/pullRequest/GiteaConversation.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; import { editableCommentId, @@ -23,10 +24,10 @@ describe("GiteaConversation", () => { it("groups supported Gitea reactions and names the signed-in viewer separately", () => { const rows: ReadonlyArray = [ - { reaction: "+1", user: { login: "Reader" } }, - { reaction: "+1", user: { login: "teammate" } }, - { reaction: "heart", user: { login: "friend" } }, - { reaction: "party", user: { login: "ignored" } }, + { content: "+1", user: { login: "Reader" } }, + { content: "+1", user: { login: "teammate" } }, + { content: "heart", user: { login: "friend" } }, + { content: "party", user: { login: "ignored" } }, ]; expect(reactionsForViewer(rows, "reader")).toEqual([ @@ -35,6 +36,19 @@ describe("GiteaConversation", () => { ]); }); + it("decodes and groups the native Gitea reaction response shape", () => { + const decodeReaction = Schema.decodeUnknownSync(RawGiteaReaction); + const row = decodeReaction({ + content: "+1", + created_at: "2026-09-05T00:00:00Z", + user: { id: 7, login: "kalvens", full_name: "Kalven" }, + }); + + expect(reactionsForViewer([row], "Kalvens")).toEqual([ + { content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true }, + ]); + }); + it("uses Gitea's reaction spelling on writes", () => { expect(nativeReactionContent("thumbs-up")).toBe("+1"); expect(nativeReactionContent("heart")).toBe("heart"); diff --git a/apps/server/src/pullRequest/GiteaConversation.ts b/apps/server/src/pullRequest/GiteaConversation.ts index 6fa274a540e1..81642a4ac481 100644 --- a/apps/server/src/pullRequest/GiteaConversation.ts +++ b/apps/server/src/pullRequest/GiteaConversation.ts @@ -7,7 +7,7 @@ const RawReactionUser = Schema.Struct({ /** The shape returned by Gitea's issue and issue-comment reaction endpoints. */ export const RawGiteaReaction = Schema.Struct({ - reaction: Schema.optional(Schema.String), + content: Schema.String, user: Schema.optional(Schema.NullOr(RawReactionUser)), }); @@ -67,7 +67,7 @@ export function reactionsForViewer( { count: number; actors: Array; viewerHasReacted: boolean } >(); for (const row of rows) { - const content = row.reaction === undefined ? undefined : reactionContent.get(row.reaction); + const content = reactionContent.get(row.content); if (content === undefined) continue; const group = groups.get(content) ?? { count: 0, actors: [], viewerHasReacted: false }; group.count += 1; diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 1e4d08cff438..ff4f4452293b 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -896,13 +896,13 @@ layer("GiteaPullRequestApi", (it) => { .mockReturnValueOnce( Effect.succeed( response([ - { reaction: "+1", user: { login: "reader" } }, - { reaction: "+1", user: { login: "teammate" } }, + { content: "+1", user: { login: "reader" } }, + { content: "+1", user: { login: "teammate" } }, ]), ), ) .mockReturnValueOnce( - Effect.succeed(response([{ reaction: "heart", user: { login: "friend" } }])), + Effect.succeed(response([{ content: "heart", user: { login: "friend" } }])), ) .mockReturnValueOnce(Effect.succeed(response([]))); const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; From eaabe9a6b71ffd49aeb9caeb3d7cc5c6a472505f Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 01:29:37 -0500 Subject: [PATCH 2/6] fix(pull-requests): accept empty Gitea reaction lists --- .../pullRequest/GiteaPullRequestApi.test.ts | 58 ++++++++++++++++++- .../src/pullRequest/GiteaPullRequestApi.ts | 55 +++++++++++++----- 2 files changed, 97 insertions(+), 16 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index ff4f4452293b..a4b9028c4d3f 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -923,9 +923,61 @@ layer("GiteaPullRequestApi", (it) => { expect(reactions.bySubjectId.get("review-comment:34")).toEqual([]); expect(reactions.bySubjectId.has("review:21")).toBe(false); expect(mockedRequest.mock.calls.map((call) => call[0].path)).toEqual([ - "/repos/acme/web/issues/7/reactions", - "/repos/acme/web/issues/comments/12/reactions", - "/repos/acme/web/issues/comments/34/reactions", + "/repos/acme/web/issues/7/reactions?page=1&limit=50", + "/repos/acme/web/issues/comments/12/reactions?page=1&limit=50", + "/repos/acme/web/issues/comments/34/reactions?page=1&limit=50", + ]); + }), + ); + + it.effect("treats a native null reaction list as empty without dropping other subjects", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(null))) + .mockReturnValueOnce( + Effect.succeed(response([{ content: "heart", user: { login: "friend" } }])), + ); + const api = yield* GiteaPullRequestApi.make; + const reactions = yield* api.listConversationReactions({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + viewer: "Reader", + subjectIds: ["issue:12"], + }); + + expect(reactions.pullRequest).toEqual([]); + expect(reactions.bySubjectId.get("issue:12")).toEqual([ + { content: "heart", count: 1, actors: ["friend"], viewerHasReacted: false }, + ]); + }), + ); + + it.effect("follows a reaction list when Gitea caps a requested page below its limit", () => + Effect.gen(function* () { + mockedRequest.mockImplementation((input) => { + if (input.path === "/settings/api") return Effect.succeed(response({ features: [] })); + if (input.path === "/repos/acme/web/issues/7/reactions?page=1&limit=50") + return Effect.succeed( + response([{ content: "heart", user: { login: "one" } }], { "x-total-count": "2" }), + ); + if (input.path === "/repos/acme/web/issues/7/reactions?page=2&limit=50") + return Effect.succeed( + response([{ content: "eyes", user: { login: "two" } }], { "x-total-count": "2" }), + ); + return Effect.die(`unexpected request: ${input.path}`); + }); + const api = yield* GiteaPullRequestApi.make; + const reactions = yield* api.listConversationReactions({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + viewer: "reader", + subjectIds: [], + }); + expect(reactions.pullRequest).toEqual([ + { content: "heart", count: 1, actors: ["one"], viewerHasReacted: false }, + { content: "eyes", count: 1, actors: ["two"], viewerHasReacted: false }, ]); }), ); diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 0c4140a63d62..cae638bdd860 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -689,9 +689,23 @@ export const make = Effect.gen(function* () { host: string; repository: string; path: string; + nullAsEmpty?: boolean; }) { - const response = yield* request({ ...input, method: "GET" }); - const rows = yield* decode(input.operation, Schema.Array(Schema.Unknown), response); + const response = yield* request({ + operation: input.operation, + host: input.host, + repository: input.repository, + path: input.path, + method: "GET", + }); + const decoded = yield* decode( + input.operation, + input.nullAsEmpty + ? Schema.NullOr(Schema.Array(Schema.Unknown)) + : Schema.Array(Schema.Unknown), + response, + ); + const rows = decoded ?? []; return { rows, headers: response.headers } satisfies UnknownPage; }); @@ -706,6 +720,7 @@ export const make = Effect.gen(function* () { repository: string; path: string; limit: number; + nullAsEmpty?: boolean; }) { const rows: Array = []; let path = input.path; @@ -716,6 +731,7 @@ export const make = Effect.gen(function* () { host: input.host, repository: input.repository, path, + ...(input.nullAsEmpty === undefined ? {} : { nullAsEmpty: input.nullAsEmpty }), }); rowsSeen += result.rows.length; const remaining = Math.max(0, input.limit - rows.length); @@ -1319,24 +1335,37 @@ export const make = Effect.gen(function* () { } const reactions = yield* Effect.all( targets.map((entry) => - readUnknownArray({ + readUnknownSlice({ operation: "listConversationReactions", host: input.host, repository: input.repository, path: entry.target.kind === "pull-request" - ? `${basePath(input.repository)}/issues/${input.number}/reactions` - : `${basePath(input.repository)}/issues/comments/${entry.target.id}/reactions`, + ? query(`${basePath(input.repository)}/issues/${input.number}/reactions`, { + page: 1, + limit: PAGE_SIZE, + }) + : query( + `${basePath(input.repository)}/issues/comments/${entry.target.id}/reactions`, + { + page: 1, + limit: PAGE_SIZE, + }, + ), + limit: PAGE_SIZE * MAX_PAGINATION_PAGES, + nullAsEmpty: true, }).pipe( - Effect.map((rows) => ({ + Effect.map((result) => ({ subjectId: entry.subjectId, - reactions: reactionsForViewer( - rows.flatMap((row) => { - const decoded = decodeReaction(row); - return Option.isSome(decoded) ? [decoded.value] : []; - }), - input.viewer, - ), + reactions: result.truncated + ? [] + : reactionsForViewer( + result.rows.flatMap((row) => { + const decoded = decodeReaction(row); + return Option.isSome(decoded) ? [decoded.value] : []; + }), + input.viewer, + ), })), ), ), From 90d0bfef5c2a69c6e69f483dad773befcf30c8d7 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Mon, 7 Sep 2026 22:07:33 -0500 Subject: [PATCH 3/6] test(pull-requests): exercise reaction pagination in base slice --- apps/server/src/pullRequest/GiteaPullRequestApi.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index a4b9028c4d3f..df232d415e03 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -937,7 +937,7 @@ layer("GiteaPullRequestApi", (it) => { .mockReturnValueOnce( Effect.succeed(response([{ content: "heart", user: { login: "friend" } }])), ); - const api = yield* GiteaPullRequestApi.make; + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; const reactions = yield* api.listConversationReactions({ host: "forge.example.test", repository: "acme/web", @@ -956,7 +956,6 @@ layer("GiteaPullRequestApi", (it) => { it.effect("follows a reaction list when Gitea caps a requested page below its limit", () => Effect.gen(function* () { mockedRequest.mockImplementation((input) => { - if (input.path === "/settings/api") return Effect.succeed(response({ features: [] })); if (input.path === "/repos/acme/web/issues/7/reactions?page=1&limit=50") return Effect.succeed( response([{ content: "heart", user: { login: "one" } }], { "x-total-count": "2" }), @@ -967,7 +966,7 @@ layer("GiteaPullRequestApi", (it) => { ); return Effect.die(`unexpected request: ${input.path}`); }); - const api = yield* GiteaPullRequestApi.make; + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; const reactions = yield* api.listConversationReactions({ host: "forge.example.test", repository: "acme/web", From 53402b8077a15ee8038c275954c542b7dcda1de8 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Mon, 7 Sep 2026 22:11:52 -0500 Subject: [PATCH 4/6] test(server): match paginated Gitea review comment reads --- .../src/pullRequest/GiteaPullRequestProvider.activity.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.activity.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.activity.test.ts index 333def121618..0fa7ec418f95 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.activity.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.activity.test.ts @@ -58,7 +58,7 @@ function route(viewerFails: boolean, reactionsFail: boolean) { return Effect.succeed( response([{ id: 2, body: "summary", submitted_at: "2026-01-01T00:00:00Z" }]), ); - if (input.path === "/repos/acme/web/pulls/7/reviews/2/comments") + if (input.path === "/repos/acme/web/pulls/7/reviews/2/comments?page=1&limit=50") return Effect.succeed( response([ { id: 3, body: "inline", created_at: "2026-01-01T00:00:00Z", path: "a.ts", position: 1 }, From 014e1c2122784e5a367841c9052d6bab103a3cb0 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Mon, 7 Sep 2026 23:05:01 -0500 Subject: [PATCH 5/6] fix(gitea): retain complete native comment reactions --- .../pullRequest/GiteaPullRequestApi.test.ts | 70 ++++++++++++++++++- .../src/pullRequest/GiteaPullRequestApi.ts | 42 +++++------ 2 files changed, 90 insertions(+), 22 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 9d757d71d4c6..322f58d62a2f 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -1225,8 +1225,8 @@ layer("GiteaPullRequestApi", (it) => { expect(reactions.bySubjectId.has("review:21")).toBe(false); expect(mockedRequest.mock.calls.map((call) => call[0].path)).toEqual([ "/repos/acme/web/issues/7/reactions?page=1&limit=50", - "/repos/acme/web/issues/comments/12/reactions?page=1&limit=50", - "/repos/acme/web/issues/comments/34/reactions?page=1&limit=50", + "/repos/acme/web/issues/comments/12/reactions", + "/repos/acme/web/issues/comments/34/reactions", ]); }), ); @@ -1282,6 +1282,72 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect.each([50, 200, 201])( + "retains all %i native comment reactions with one unpaginated read", + (count) => + Effect.gen(function* () { + mockedRequest.mockImplementation((input) => { + if (input.path.startsWith("/repos/acme/web/issues/7/reactions?")) + return Effect.succeed(response([])); + if (input.path === "/repos/acme/web/issues/comments/12/reactions") + return Effect.succeed( + response( + Array.from({ length: count }, (_, index) => ({ + content: "+1", + user: { login: `reader-${index}` }, + })), + ), + ); + return Effect.die(`unexpected request: ${input.path}`); + }); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const reactions = yield* api.listConversationReactions({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + viewer: "reader-0", + subjectIds: ["issue:12"], + }); + + expect(reactions.bySubjectId.get("issue:12")).toEqual([ + expect.objectContaining({ content: "thumbs-up", count, viewerHasReacted: true }), + ]); + expect(mockedRequest).toHaveBeenCalledTimes(2); + }), + ); + + it.effect.each([false, true])( + "bounds paginated issue reactions with pagination evidence: %s", + (hasEvidence) => + Effect.gen(function* () { + mockedRequest.mockImplementation((input) => { + const page = Number( + new URL(input.path, "https://forge.example.test").searchParams.get("page"), + ); + return Effect.succeed( + response( + Array.from({ length: 50 }, (_, index) => ({ + content: "+1", + user: { login: `reader-${page}-${index}` }, + })), + hasEvidence ? { "x-total-count": "201" } : {}, + ), + ); + }); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const reactions = yield* api.listConversationReactions({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + viewer: "reader", + subjectIds: [], + }); + + expect(reactions.pullRequest).toEqual([]); + expect(mockedRequest).toHaveBeenCalledTimes(hasEvidence ? 4 : 1); + }), + ); + it.effect("reports Gitea's missing review-summary reaction route without issuing a request", () => Effect.gen(function* () { const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 387dc9cad890..12eba4f0fab0 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -1347,26 +1347,28 @@ export const make = Effect.gen(function* () { } const reactions = yield* Effect.all( targets.map((entry) => - readUnknownSlice({ - operation: "listConversationReactions", - host: input.host, - repository: input.repository, - path: - entry.target.kind === "pull-request" - ? query(`${basePath(input.repository)}/issues/${input.number}/reactions`, { - page: 1, - limit: PAGE_SIZE, - }) - : query( - `${basePath(input.repository)}/issues/comments/${entry.target.id}/reactions`, - { - page: 1, - limit: PAGE_SIZE, - }, - ), - limit: PAGE_SIZE * MAX_PAGINATION_PAGES, - nullAsEmpty: true, - }).pipe( + (entry.target.kind === "comment" + ? // Gitea's comment-reaction handler returns the whole list and ignores pagination. + readUnknownPage({ + operation: "listConversationReactions", + host: input.host, + repository: input.repository, + path: `${basePath(input.repository)}/issues/comments/${entry.target.id}/reactions`, + nullAsEmpty: true, + }).pipe(Effect.map(({ rows }) => ({ rows, truncated: false }))) + : readUnknownSlice({ + operation: "listConversationReactions", + host: input.host, + repository: input.repository, + path: query(`${basePath(input.repository)}/issues/${input.number}/reactions`, { + page: 1, + limit: PAGE_SIZE, + }), + limit: PAGE_SIZE * CONVERSATION_PAGES, + requirePaginationEvidence: true, + nullAsEmpty: true, + }) + ).pipe( Effect.map((result) => ({ subjectId: entry.subjectId, reactions: result.truncated From b120bd9fa5f040c8cbde97e4e69fd7f23b701362 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Mon, 7 Sep 2026 23:13:07 -0500 Subject: [PATCH 6/6] fix(gitea): remove duplicate reaction 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 dbfa5209870f..a8eb58a60cdc 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -1053,7 +1053,6 @@ export const make = Effect.gen(function* () { limit: number; nullAsEmpty?: boolean; requirePaginationEvidence?: boolean; - nullAsEmpty?: boolean; }) { const rows: Array = []; let path = input.path;