diff --git a/apps/server/src/pullRequest/GiteaConversation.test.ts b/apps/server/src/pullRequest/GiteaConversation.test.ts new file mode 100644 index 000000000000..b9aad258c90e --- /dev/null +++ b/apps/server/src/pullRequest/GiteaConversation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + editableCommentId, + nativeReactionContent, + RawGiteaReaction, + reactionsForViewer, + reactionTarget, +} from "./GiteaConversation.ts"; + +describe("GiteaConversation", () => { + it("addresses ordinary and inline review remarks through the same issue-comment record", () => { + expect(editableCommentId("issue:12")).toBe("12"); + expect(editableCommentId("review-comment:34")).toBe("34"); + expect(reactionTarget("review:56")).toBeNull(); + }); + + it("distinguishes a pull request description from a comment and rejects malformed ids", () => { + expect(reactionTarget(undefined)).toEqual({ kind: "pull-request" }); + expect(reactionTarget("issue:12")).toEqual({ kind: "comment", id: "12" }); + expect(editableCommentId("issue:12/../../private")).toBeNull(); + }); + + 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" } }, + ]; + + expect(reactionsForViewer(rows, "reader")).toEqual([ + { content: "thumbs-up", count: 2, actors: ["teammate"], viewerHasReacted: true }, + { content: "heart", count: 1, actors: ["friend"], viewerHasReacted: false }, + ]); + }); + + 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 new file mode 100644 index 000000000000..6fa274a540e1 --- /dev/null +++ b/apps/server/src/pullRequest/GiteaConversation.ts @@ -0,0 +1,82 @@ +import * as Schema from "effect/Schema"; +import type { PullRequestReaction, PullRequestReactionContent } from "@t3tools/contracts"; + +const RawReactionUser = Schema.Struct({ + login: Schema.optional(Schema.String), +}); + +/** The shape returned by Gitea's issue and issue-comment reaction endpoints. */ +export const RawGiteaReaction = Schema.Struct({ + reaction: Schema.optional(Schema.String), + user: Schema.optional(Schema.NullOr(RawReactionUser)), +}); + +export type GiteaConversationReactionTarget = + | { readonly kind: "pull-request" } + | { readonly kind: "comment"; readonly id: string }; + +const reactionContent = new Map([ + ["+1", "thumbs-up"], + ["-1", "thumbs-down"], + ["laugh", "laugh"], + ["hooray", "hooray"], + ["confused", "confused"], + ["heart", "heart"], + ["rocket", "rocket"], + ["eyes", "eyes"], +]); + +const giteaReactionContent = new Map( + [...reactionContent].map(([gitea, content]) => [content, gitea]), +); + +function commentId(subjectId: string): string | null { + const [kind, id] = subjectId.split(":", 2); + if ((kind !== "issue" && kind !== "review-comment") || !id || !/^\d+$/.test(id)) return null; + return id; +} + +/** + * Gitea stores inline review remarks as issue comments. Review summaries use a separate Review + * record, for which v1.27.3 intentionally exposes neither an edit nor a reaction endpoint. + */ +export function reactionTarget( + subjectId: string | undefined, +): GiteaConversationReactionTarget | null { + if (subjectId === undefined) return { kind: "pull-request" }; + const id = commentId(subjectId); + return id === null ? null : { kind: "comment", id }; +} + +/** Returns the native issue-comment ID for both ordinary and inline-review remarks. */ +export function editableCommentId(subjectId: string): string | null { + return commentId(subjectId); +} + +export function nativeReactionContent(content: PullRequestReactionContent): string { + return giteaReactionContent.get(content) ?? content; +} + +/** Reduces Gitea's one-row-per-user reactions to the cross-provider reaction pill shape. */ +export function reactionsForViewer( + rows: ReadonlyArray, + viewer: string, +): ReadonlyArray { + const groups = new Map< + PullRequestReactionContent, + { count: number; actors: Array; viewerHasReacted: boolean } + >(); + for (const row of rows) { + const content = row.reaction === undefined ? undefined : reactionContent.get(row.reaction); + if (content === undefined) continue; + const group = groups.get(content) ?? { count: 0, actors: [], viewerHasReacted: false }; + group.count += 1; + const login = row.user?.login?.trim(); + if (login !== undefined && login !== "") { + if (login.toLowerCase() === viewer.toLowerCase()) group.viewerHasReacted = true; + else group.actors.push(login); + } + groups.set(content, group); + } + return [...groups].map(([content, group]) => ({ content, ...group })); +} diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 81751a5ca1f4..1e4d08cff438 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -821,6 +821,134 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect( + "edits ordinary and inline review comments through Gitea's issue-comment endpoint", + () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response({}))) + .mockReturnValueOnce(Effect.succeed(response({}))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + yield* api.updateComment({ + host: "forge.example.test", + repository: "acme/web", + commentId: "issue:12", + body: "Reworded.", + }); + yield* api.updateComment({ + host: "forge.example.test", + repository: "acme/web", + commentId: "review-comment:34", + body: "Also reworded.", + }); + + expect(callAt(0)).toMatchObject({ + method: "PATCH", + path: "/repos/acme/web/issues/comments/12", + }); + expect(callAt(1)).toMatchObject({ + method: "PATCH", + path: "/repos/acme/web/issues/comments/34", + }); + }), + ); + + it.effect( + "reacts to a pull request description and inline review comment through issue routes", + () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response({}))) + .mockReturnValueOnce(Effect.succeed(response({}))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + yield* api.setReaction({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + content: "thumbs-up", + reacted: true, + }); + yield* api.setReaction({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + subjectId: "review-comment:34", + content: "heart", + reacted: false, + }); + + expect(callAt(0)).toMatchObject({ + method: "POST", + path: "/repos/acme/web/issues/7/reactions", + }); + expect(decodeJson(callAt(0).body ?? "{}")).toEqual({ content: "+1" }); + expect(callAt(1)).toMatchObject({ + method: "DELETE", + path: "/repos/acme/web/issues/comments/34/reactions", + }); + expect(decodeJson(callAt(1).body ?? "{}")).toEqual({ content: "heart" }); + }), + ); + + it.effect("loads reactions for the pull request and every issue-backed remark", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response([ + { reaction: "+1", user: { login: "reader" } }, + { reaction: "+1", user: { login: "teammate" } }, + ]), + ), + ) + .mockReturnValueOnce( + Effect.succeed(response([{ reaction: "heart", user: { login: "friend" } }])), + ) + .mockReturnValueOnce(Effect.succeed(response([]))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const reactions = yield* api.listConversationReactions({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + viewer: "Reader", + subjectIds: ["issue:12", "review-comment:34", "review:21", "issue:12"], + }); + + expect(reactions.pullRequest).toEqual([ + { content: "thumbs-up", count: 2, actors: ["teammate"], viewerHasReacted: true }, + ]); + expect(reactions.bySubjectId.get("issue:12")).toEqual([ + { content: "heart", count: 1, actors: ["friend"], viewerHasReacted: false }, + ]); + 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", + ]); + }), + ); + + it.effect("reports Gitea's missing review-summary reaction route without issuing a request", () => + Effect.gen(function* () { + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const error = yield* api + .setReaction({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + subjectId: "review:21", + content: "eyes", + reacted: true, + }) + .pipe(Effect.flip); + + expect(error.detail).toContain("review summaries"); + assert.strictEqual(mockedRequest.mock.calls.length, 0); + }), + ); + it.effect("preserves existing labels when adding another", () => Effect.gen(function* () { mockedRequest diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index c9ae5268ab2c..0c4140a63d62 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -17,6 +17,8 @@ import type { PullRequestMergeCapabilities, PullRequestMergeMethod, PullRequestMergeability, + PullRequestReaction, + PullRequestReactionContent, PullRequestReviewCommentDraft, PullRequestReviewThread, PullRequestReviewVerdict, @@ -26,6 +28,14 @@ import type { import * as GiteaApi from "../sourceControl/GiteaApi.ts"; import * as GiteaLifecycle from "./GiteaLifecycle.ts"; +import { + editableCommentId, + type GiteaConversationReactionTarget, + nativeReactionContent, + RawGiteaReaction, + reactionsForViewer, + reactionTarget, +} from "./GiteaConversation.ts"; import type { ProviderListCursor } from "./PullRequestProvider.ts"; import { dedupeChecks } from "./pullRequestChecks.ts"; @@ -186,6 +196,7 @@ const decodeReview = Schema.decodeUnknownOption(RawReview); const decodeReviewComment = Schema.decodeUnknownOption(RawReviewComment); const decodeCommit = Schema.decodeUnknownOption(RawCommit); const decodeLabel = Schema.decodeUnknownOption(RawLabel); +const decodeReaction = Schema.decodeUnknownOption(RawGiteaReaction); const encodeObject = Schema.encodeSync( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), ); @@ -454,6 +465,19 @@ export class GiteaPullRequestApi extends Context.Service< }, GiteaPullRequestApiError >; + readonly listConversationReactions: (input: { + host: string; + repository: string; + number: number; + viewer: string; + subjectIds: ReadonlyArray; + }) => Effect.Effect< + { + pullRequest: ReadonlyArray; + bySubjectId: ReadonlyMap>; + }, + GiteaPullRequestApiError + >; readonly listCommits: (input: { host: string; repository: string; @@ -556,7 +580,7 @@ export class GiteaPullRequestApi extends Context.Service< repository: string; number: number; subjectId?: string; - content: string; + content: PullRequestReactionContent; reacted: boolean; }) => Effect.Effect; } @@ -1277,6 +1301,58 @@ export const make = Effect.gen(function* () { }); }); + const listConversationReactions = Effect.fn("GiteaPullRequestApi.listConversationReactions")( + function* (input: { + host: string; + repository: string; + number: number; + viewer: string; + subjectIds: ReadonlyArray; + }) { + const targets: Array<{ + readonly subjectId: string | undefined; + readonly target: GiteaConversationReactionTarget; + }> = [{ subjectId: undefined, target: { kind: "pull-request" } }]; + for (const subjectId of new Set(input.subjectIds)) { + const target = reactionTarget(subjectId); + if (target !== null) targets.push({ subjectId, target }); + } + const reactions = yield* Effect.all( + targets.map((entry) => + readUnknownArray({ + 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`, + }).pipe( + Effect.map((rows) => ({ + subjectId: entry.subjectId, + reactions: reactionsForViewer( + rows.flatMap((row) => { + const decoded = decodeReaction(row); + return Option.isSome(decoded) ? [decoded.value] : []; + }), + input.viewer, + ), + })), + ), + ), + { concurrency: 10 }, + ); + return { + pullRequest: reactions.find((entry) => entry.subjectId === undefined)?.reactions ?? [], + bySubjectId: new Map( + reactions.flatMap((entry) => + entry.subjectId === undefined ? [] : [[entry.subjectId, entry.reactions] as const], + ), + ), + }; + }, + ); + const unsupportedAction = (action: string) => new GiteaPullRequestApiError({ operation: "runAction", @@ -1309,6 +1385,7 @@ export const make = Effect.gen(function* () { getAutoMergeEnabled, listComments, listReviews, + listConversationReactions, listCommits, listChecks, getDiff: (input) => @@ -1500,13 +1577,13 @@ export const make = Effect.gen(function* () { body: { body: input.body }, }), updateComment: (input) => { - const [kind, id] = input.commentId.split(":", 2); - if (kind !== "issue" || !id) { + const id = editableCommentId(input.commentId); + if (id === null) { return Effect.fail( new GiteaPullRequestApiError({ operation: "updateComment", reason: "failed", - detail: "Gitea cannot edit pull request review comments through this API.", + detail: "Gitea cannot edit pull request review summaries through this API.", }), ); } @@ -1668,14 +1745,30 @@ export const make = Effect.gen(function* () { method: "POST", path: `${basePath(input.repository)}/pulls/comments/${encodeURIComponent(input.threadId)}/${input.resolved ? "resolve" : "unresolve"}`, }), - setReaction: () => - Effect.fail( - new GiteaPullRequestApiError({ - operation: "setReaction", - reason: "failed", - detail: "Gitea cannot apply reactions consistently to pull request review comments.", - }), - ), + setReaction: (input) => { + const target = reactionTarget(input.subjectId); + if (target === null) { + return Effect.fail( + new GiteaPullRequestApiError({ + operation: "setReaction", + reason: "failed", + detail: "Gitea cannot react to pull request review summaries through this API.", + }), + ); + } + const path = + target.kind === "pull-request" + ? `${basePath(input.repository)}/issues/${input.number}/reactions` + : `${basePath(input.repository)}/issues/comments/${target.id}/reactions`; + return write({ + operation: "setReaction", + host: input.host, + repository: input.repository, + method: input.reacted ? "POST" : "DELETE", + path, + body: { content: nativeReactionContent(input.content) }, + }); + }, }); }); diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.activity.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.activity.test.ts new file mode 100644 index 000000000000..333def121618 --- /dev/null +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.activity.test.ts @@ -0,0 +1,97 @@ +import { assert, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as GiteaApi from "../sourceControl/GiteaApi.ts"; +import * as GiteaPullRequestApi from "./GiteaPullRequestApi.ts"; +import * as GiteaPullRequestProvider from "./GiteaPullRequestProvider.ts"; + +const request = vi.fn(); + +const response = (value: unknown) => ({ + body: JSON.stringify(value), + truncated: false, + headers: {}, +}); +const failure = () => + Effect.fail(new GiteaApi.GiteaApiError({ operation: "test", reason: "failed", detail: "offline" })); + +const pull = { + number: 7, + title: "PR", + body: "body", + state: "open", + merged: false, + draft: false, + html_url: "https://forge.example.test/acme/web/pulls/7", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + base: { ref: "main", sha: "base", repo: { full_name: "acme/web" } }, + head: { ref: "feature", sha: "head", repo: { full_name: "acme/web" } }, +}; + +const apiLayer = GiteaPullRequestApi.layer.pipe( + Layer.provide( + Layer.succeed( + GiteaApi.GiteaApi, + GiteaApi.GiteaApi.of({ + baseUrl: Option.some("https://forge.example.test"), + request, + probeAuth: Effect.die("unused"), + }), + ), + ), +); + +function route(viewerFails: boolean, reactionsFail: boolean) { + request.mockImplementation((input) => { + if (input.path === "/user") + return viewerFails ? failure() : Effect.succeed(response({ login: "reader" })); + if (input.path === "/settings/api") return Effect.succeed(response({ features: [] })); + if (input.path === "/repos/acme/web/pulls/7") return Effect.succeed(response(pull)); + if (input.path === "/repos/acme/web/issues/7/comments?page=1&limit=50") + return Effect.succeed( + response([{ id: 1, body: "ordinary", created_at: "2026-01-01T00:00:00Z" }]), + ); + if (input.path === "/repos/acme/web/pulls/7/reviews?page=1&limit=50") + 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") + return Effect.succeed( + response([ + { id: 3, body: "inline", created_at: "2026-01-01T00:00:00Z", path: "a.ts", position: 1 }, + ]), + ); + if (input.path === "/repos/acme/web/pulls/7/commits?page=1&limit=50") + return Effect.succeed(response([])); + if (input.path.includes("/reactions?")) + return reactionsFail ? failure() : Effect.succeed(response([])); + return Effect.die(`unexpected ${input.path}`); + }); +} + +for (const [name, viewerFails, reactionsFail] of [ + ["viewer", true, false], + ["reactions", false, true], +] as const) { + it.effect(`keeps loaded conversation when ${name} enrichment fails`, () => + Effect.gen(function* () { + route(viewerFails, reactionsFail); + const provider = yield* GiteaPullRequestProvider.make.pipe(Effect.provide(apiLayer)); + const activity = yield* provider.getChangeRequestActivity({ + cwd: "/tmp", + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + expect(activity.comments.map((comment) => comment.body)).toEqual([ + "ordinary", + "summary", + "inline", + ]); + assert.strictEqual(activity.reviewThreads[0]?.comments[0]?.body, "inline"); + }), + ); +} diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts index dc882dbe7b8b..b28fd4443d0c 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts @@ -29,9 +29,15 @@ const CAPABILITIES: PullRequestCapabilities = { // Gitea's repository pull listing has no text parameter. Returning an unfiltered page keeps // narrowing correct at the service boundary without claiming host-side search. search: false, - // Issue reactions exist, but review-comment reactions do not have a corresponding route in - // the target API. The one capability covers every displayed remark, so partial support stays off. + // Review summaries have no Gitea reaction route. Conversation rows carry reactions only for + // target kinds the host supports; the provider must not claim the legacy all-remarks flag. reactions: false, + reactionSubjects: { + changeRequest: true, + issueComment: true, + reviewComment: true, + review: false, + }, review: { inlineComment: true, reply: true, @@ -39,9 +45,9 @@ const CAPABILITIES: PullRequestCapabilities = { verdicts: ["comment", "approve", "request-changes"], }, reviewers: { request: true, listCandidates: true }, - // Gitea can edit the pull request and ordinary issue comments. It has no edit route for a - // review comment, and this capability applies to every conversation remark. - edit: { changeRequest: true, comment: false }, + // Inline review comments are issue-comment records in Gitea and share this PATCH route. + // A review summary is a separate Review record and is not a rewriteable comment in this API. + edit: { changeRequest: true, comment: true }, labels: true, }; @@ -217,26 +223,60 @@ export const make = Effect.gen(function* () { .listReviews(input) .pipe(Effect.orElseSucceed(() => ({ comments: [], threads: [], truncated: true }))), api.listCommits(input).pipe(Effect.orElseSucceed(() => [])), + api.getViewer().pipe(Effect.orElseSucceed(() => "")), ], - { concurrency: 4 }, + { concurrency: 5 }, ).pipe( Effect.mapError(fail("getChangeRequestActivity")), - Effect.map( - ([pullRequest, issueComments, reviews, commits]): ProviderChangeRequestActivity => ({ - author: pullRequest.author, - reviewers: pullRequest.reviewers, - comments: [...issueComments.comments, ...reviews.comments].toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ), - commentCount: Math.max( - pullRequest.commentCount, - issueComments.comments.length + reviews.comments.length, - ), - commentsTruncated: issueComments.truncated || reviews.truncated, - reviewThreads: reviews.threads, - commits, - }), - ), + Effect.flatMap(([pullRequest, issueComments, reviews, commits, viewer]) => { + const reactions = + viewer === "" + ? Effect.succeed({ pullRequest: [], bySubjectId: new Map() }) + : api + .listConversationReactions({ + ...input, + viewer, + subjectIds: [...issueComments.comments, ...reviews.comments].map( + (comment) => comment.id, + ), + }) + .pipe( + Effect.orElseSucceed(() => ({ + pullRequest: [], + bySubjectId: new Map(), + })), + ); + return reactions.pipe( + Effect.map((reactions): ProviderChangeRequestActivity => ({ + author: pullRequest.author, + reviewers: pullRequest.reviewers, + comments: [...issueComments.comments, ...reviews.comments] + .map((comment) => { + const remarkReactions = reactions.bySubjectId.get(comment.id); + return remarkReactions === undefined + ? comment + : { ...comment, reactions: remarkReactions }; + }) + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), + commentCount: Math.max( + pullRequest.commentCount, + issueComments.comments.length + reviews.comments.length, + ), + commentsTruncated: issueComments.truncated || reviews.truncated, + reviewThreads: reviews.threads.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => { + const remarkReactions = reactions.bySubjectId.get(comment.id); + return remarkReactions === undefined + ? comment + : { ...comment, reactions: remarkReactions }; + }), + })), + commits, + reactions: reactions.pullRequest, + })), + ); + }), ), getViewerPermissions: (input) => @@ -290,7 +330,6 @@ export const make = Effect.gen(function* () { comment: (input) => api.comment(input).pipe(Effect.mapError(fail("comment"))), - // Never called: Gitea cannot edit every kind of remark, and the capability stays false. updateComment: (input) => api.updateComment(input).pipe(Effect.mapError(fail("updateComment"))), submitReview: (input) => api.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), @@ -311,7 +350,6 @@ export const make = Effect.gen(function* () { setThreadResolution: (input) => api.setThreadResolution(input).pipe(Effect.mapError(fail("setThreadResolution"))), - // Never called: the target API cannot cover reactions on review comments. setReaction: (input) => api .setReaction({ diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index ceba7ce32e04..54d1f510c84b 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -14,6 +14,7 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import { PullRequestOperationError, PullRequestUnavailableError, + pullRequestCanReact, pullRequestHostOf, pullRequestProviderRequirement, resolvePullRequestAuthorFilter, @@ -1771,11 +1772,19 @@ export const make = Effect.gen(function* () { const setReaction: PullRequestService["Service"]["setReaction"] = (input) => requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { - if (project.api.capabilities.reactions !== true) { + const subject = + input.subjectId === undefined + ? "change-request" + : input.subjectId.startsWith("issue:") + ? "issue-comment" + : input.subjectId.startsWith("review-comment:") + ? "review-comment" + : "review"; + if (!pullRequestCanReact(project.api.capabilities, subject)) { return Effect.fail( new PullRequestOperationError({ operation: "setReaction", - detail: "This host has no reactions.", + detail: "This host cannot react to this part of the conversation.", }), ); } diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index aa2d278ce249..c4e1d98e2a28 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -10,6 +10,7 @@ import type { PullRequestReviewThread, PullRequestThreadCommentsResult, } from "@t3tools/contracts"; +import { pullRequestCanReact } from "@t3tools/contracts"; import { ChevronDownIcon, ChevronRightIcon, @@ -815,7 +816,7 @@ export function PullRequestCodeTab({ workspaceRoot={detail.workspaceRoot} canReply={review.reply} canResolve={review.resolve} - canReact={detail.capabilities.reactions === true} + canReact={pullRequestCanReact(detail.capabilities, "review-comment")} environmentId={environmentId} reference={reference} pending={threadPending} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index b06630f3bd04..223e7a11c877 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -6,6 +6,7 @@ import type { PullRequestRef, ScopedThreadRef, } from "@t3tools/contracts"; +import { pullRequestCanReact } from "@t3tools/contracts"; import { ArrowDownUpIcon, ChevronDownIcon, @@ -777,7 +778,7 @@ export function PullRequestSummaryTab({ boolean; readonly environmentId: EnvironmentId; /** Thread the timeline is shown beside, so body links can open in its in-app browser. */ readonly threadRef: ScopedThreadRef | null; @@ -274,11 +275,11 @@ function ConversationCard({ /> ) : null} - {reactions.canReact || event.reactions.length > 0 ? ( + {reactions.canReact(event) || event.reactions.length > 0 ? (
) : null} - {reactions.canReact || event.reactions.length > 0 ? ( + {reactions.canReact(event) || event.reactions.length > 0 ? ( + pullRequestCanReact( + detail.capabilities, + event.kind === "review" + ? "review" + : event.id.startsWith("review-comment:") + ? "review-comment" + : "issue-comment", + ), environmentId, threadRef, reference, diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index 599006994d00..480def670235 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -7,6 +7,7 @@ import { PullRequestListInput, PullRequestListResult, PullRequestReviewerRequestInput, + pullRequestCanReact, resolvePullRequestAuthorFilter, } from "./pullRequest.ts"; @@ -237,6 +238,25 @@ describe("PullRequestCapabilities", () => { it("decodes a server that says nothing about reactions as a server with none", () => { expect(decodeCapabilities(base).reactions).toBeUndefined(); }); + + it("keeps the legacy all-subject reaction flag while allowing an exact subject gate", () => { + const granular = decodeCapabilities({ + ...base, + reactions: false, + reactionSubjects: { + changeRequest: true, + issueComment: true, + reviewComment: true, + review: false, + }, + }); + + expect(pullRequestCanReact(granular, "review-comment")).toBe(true); + expect(pullRequestCanReact(granular, "review")).toBe(false); + expect(pullRequestCanReact(decodeCapabilities({ ...base, reactions: true }), "review")).toBe( + true, + ); + }); }); describe("naming the reader as the author to narrow by", () => { diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 401f8161e33a..74ea49ceb9e2 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -352,6 +352,46 @@ export const PullRequestEditCapabilities = Schema.Struct({ }); export type PullRequestEditCapabilities = typeof PullRequestEditCapabilities.Type; +/** A reaction target as the pull request conversation presents it. */ +export const PullRequestReactionSubject = Schema.Literals([ + "change-request", + "issue-comment", + "review-comment", + "review", +]); +export type PullRequestReactionSubject = typeof PullRequestReactionSubject.Type; + +/** + * Hosts sometimes expose reactions for issue-backed records but not review summaries. This keeps + * that boundary visible without weakening the original all-subject `reactions` capability. + */ +export const PullRequestReactionCapabilities = Schema.Struct({ + changeRequest: Schema.Boolean, + issueComment: Schema.Boolean, + reviewComment: Schema.Boolean, + review: Schema.Boolean, +}); +export type PullRequestReactionCapabilities = typeof PullRequestReactionCapabilities.Type; + +export function pullRequestCanReact( + capabilities: PullRequestCapabilities, + subject: PullRequestReactionSubject, +): boolean { + if (capabilities.reactions === true) return true; + const granular = capabilities.reactionSubjects; + if (granular === undefined) return false; + switch (subject) { + case "change-request": + return granular.changeRequest; + case "issue-comment": + return granular.issueComment; + case "review-comment": + return granular.reviewComment; + case "review": + return granular.review; + } +} + /** * What a host can do about who reviews. The two are independent: a host can take a request without * publishing who may receive one, which is Azure DevOps. @@ -404,6 +444,11 @@ export const PullRequestCapabilities = Schema.Struct({ * what every server before this field was. */ reactions: Schema.optional(Schema.Boolean), + /** + * Per-subject reaction routes, for hosts that cannot support every conversation record. This + * augments the legacy all-subject flag; when that flag is true every subject remains enabled. + */ + reactionSubjects: Schema.optional(PullRequestReactionCapabilities), review: PullRequestReviewCapabilities, reviewers: PullRequestReviewerCapabilities, /**