Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions apps/server/src/pullRequest/GiteaConversation.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof RawGiteaReaction.Type> = [
{ 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");
});
});
82 changes: 82 additions & 0 deletions apps/server/src/pullRequest/GiteaConversation.ts
Original file line number Diff line number Diff line change
@@ -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<string, PullRequestReactionContent>([
["+1", "thumbs-up"],
["-1", "thumbs-down"],
["laugh", "laugh"],
["hooray", "hooray"],
["confused", "confused"],
["heart", "heart"],
["rocket", "rocket"],
["eyes", "eyes"],
]);

const giteaReactionContent = new Map<PullRequestReactionContent, string>(
[...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<typeof RawGiteaReaction.Type>,
viewer: string,
): ReadonlyArray<PullRequestReaction> {
const groups = new Map<
PullRequestReactionContent,
{ count: number; actors: Array<string>; 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 }));
}
128 changes: 128 additions & 0 deletions apps/server/src/pullRequest/GiteaPullRequestApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading