diff --git a/apps/server/src/pullRequest/GiteaConversation.test.ts b/apps/server/src/pullRequest/GiteaConversation.test.ts index c36ceecf1d94..23afa88ba6b3 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 2831c6cdd992..9fc61dad9d96 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)), }); @@ -71,7 +71,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 2967f4b534d7..fde03eb034fe 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -1547,13 +1547,17 @@ layer("GiteaPullRequestApi", (it) => { .mockReturnValueOnce( Effect.succeed( response([ - { reaction: "+1", user: { login: "reader" } }, - { reaction: "+1", user: { login: "teammate" } }, + { + content: "+1", + created_at: "2026-09-05T00:00:00Z", + 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.make; @@ -1582,17 +1586,41 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect("treats a native null reaction list as empty without dropping other subjects", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response({ features: [] }))) + .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([{ reaction: "heart", user: { login: "one" } }], { "x-total-count": "2" }), + 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([{ reaction: "eyes", user: { login: "two" } }], { "x-total-count": "2" }), + response([{ content: "eyes", user: { login: "two" } }], { "x-total-count": "2" }), ); return Effect.die(`unexpected request: ${input.path}`); }); diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 9efcc25cee36..33c133749443 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -845,9 +845,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; }); @@ -1022,6 +1036,7 @@ export const make = Effect.gen(function* () { path: string; limit: number; requirePaginationEvidence?: boolean; + nullAsEmpty?: boolean; }) { const rows: Array = []; let path = input.path; @@ -1032,6 +1047,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); @@ -1681,6 +1697,7 @@ export const make = Effect.gen(function* () { }, ), limit: PAGE_SIZE * MAX_PAGINATION_PAGES, + nullAsEmpty: true, }).pipe( Effect.map((result) => ({ subjectId: entry.subjectId,