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
58 changes: 58 additions & 0 deletions apps/server/src/pullRequest/GitLabPullRequestCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,64 @@ layer("GitLabPullRequestCli.layer", (it) => {
}),
);

it.effect("reads approval history after a full page of unrelated system notes", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValueOnce(
Effect.succeed(
output(
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify(
Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
system: true,
body: "assigned to @reviewer",
created_at: "2026-07-01T00:00:00Z",
})),
),
),
),
);
mockedExecute.mockReturnValueOnce(
Effect.succeed(
output(
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify([
{
id: 101,
system: true,
body: "approved this merge request",
author: { username: "reviewer" },
created_at: "2026-07-02T00:00:00Z",
},
{
id: 102,
system: true,
body: "unapproved this merge request",
author: { username: "reviewer" },
created_at: "2026-07-03T00:00:00Z",
},
]),
),
),
);

const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli;
const { comments, truncated } = yield* cli.listNotes({
cwd: "/w",
repository: "acme/web",
number: 7,
});

expect(comments).toMatchObject([
{ id: "101", kind: "review", reviewState: "APPROVED" },
{ id: "102", kind: "review", reviewState: "DISMISSED" },
]);
expect(argsOfCall(1).join(" ")).toContain("page=2");
assert.strictEqual(mockedExecute.mock.calls.length, 2);
assert.isFalse(truncated);
}),
);

it.effect("stops the note walk at its bound and says the conversation was cut short", () =>
Effect.gen(function* () {
// GitLab that never answers short: the walk has to end itself.
Expand Down
65 changes: 65 additions & 0 deletions apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,71 @@ describe("decodeViewerJson", () => {
});

describe("decodeNotesJson", () => {
it("retains approval and withdrawal notes without classifying authored comments as verdicts", () => {
const notes = expectSuccess(
decodeNotesJson(
JSON.stringify([
{
id: 1,
body: "approved this merge request",
author: { username: "reviewer" },
system: true,
created_at: "2026-07-01T00:00:00Z",
},
{
id: 2,
body: "unapproved this merge request",
author: { username: "reviewer" },
system: true,
created_at: "2026-07-02T00:00:00Z",
},
{
id: 3,
body: "approved this merge request",
author: { username: "author" },
system: false,
created_at: "2026-07-03T00:00:00Z",
},
{
id: 4,
body: "assigned to @reviewer",
system: true,
created_at: "2026-07-04T00:00:00Z",
},
{
id: 5,
body: "approved this merge request with a custom message",
system: true,
created_at: "2026-07-05T00:00:00Z",
},
]),
),
);

expect(notes.rawCount).toBe(5);
expect(notes.comments).toMatchObject([
{
id: "1",
kind: "review",
body: "approved this merge request",
author: { login: "reviewer" },
createdAt: "2026-07-01T00:00:00Z",
reviewState: "APPROVED",
path: null,
},
{
id: "2",
kind: "review",
body: "unapproved this merge request",
author: { login: "reviewer" },
createdAt: "2026-07-02T00:00:00Z",
reviewState: "DISMISSED",
path: null,
},
{ id: "3", kind: "issue-comment", reviewState: null },
]);
});

it("keeps comments and drops GitLab's own activity notes", () => {
const notes = expectSuccess(
decodeNotesJson(
Expand Down
34 changes: 23 additions & 11 deletions apps/server/src/pullRequest/gitLabMergeRequestJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,13 +515,6 @@ export function decodeProjectMergeCapabilitiesJson(
});
}

/**
* Comments only. System notes are GitLab's own activity feed entries, and a `DiffNote` is the
* root of a line-level discussion, which is what the review-comment kind means.
*
* The raw note count comes back alongside, because dropping notes hides whether the page was
* full: a caller cannot tell "no more notes" from "a page of activity entries" without it.
*/
/** The three revisions a positioned comment is written against. */
export interface GitLabDiffRefs {
readonly baseSha: string;
Expand Down Expand Up @@ -594,6 +587,21 @@ export function decodeDiffRefsJson(
);
}

function systemNoteReviewState(body: string): "APPROVED" | "DISMISSED" | null {
switch (body.trim()) {
case "approved this merge request":
return "APPROVED";
case "unapproved this merge request":
return "DISMISSED";
default:
return null;
}
}

/**
* Comments and approval history. Other system activity stays excluded. The raw count includes
* every note so a page containing only skipped activity does not end pagination early.
*/
export function decodeNotesJson(
raw: string,
): Result.Result<
Expand All @@ -609,19 +617,23 @@ export function decodeNotesJson(
const note = decodeNoteEntry(entry);
if (Exit.isFailure(note)) continue;
const value = note.value;
if (value.system === true) continue;
const body = value.body ?? "";
const reviewState = value.system === true ? systemNoteReviewState(body) : null;
if (value.system === true && reviewState === null) continue;
if (body.trim().length === 0) continue;
const isDiffNote = value.type?.trim() === "DiffNote";
comments.push({
id: String(value.id),
kind: isDiffNote ? "review-comment" : "issue-comment",
kind: reviewState !== null ? "review" : isDiffNote ? "review-comment" : "issue-comment",
author: toActor(value.author),
body,
createdAt: value.created_at,
url: null,
path: trimmed(value.position?.new_path) ?? trimmed(value.position?.old_path),
reviewState: null,
path:
reviewState !== null
? null
: (trimmed(value.position?.new_path) ?? trimmed(value.position?.old_path)),
reviewState,
});
}
return Result.succeed({ comments, rawCount: decoded.success.length });
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
import { PullRequestMarkdown } from "./PullRequestMarkdown";
import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor";
import { PullRequestReactionBar } from "./PullRequestReactions";
import { canReactPullRequestComment } from "./pullRequestReactions.logic";
import { PullRequestConversationGhost } from "./PullRequestGhosts";
import { pullRequestLabelColor } from "./pullRequestList.logic";
import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic";
Expand Down Expand Up @@ -893,7 +894,7 @@ export function PullRequestSummaryTab({
<PullRequestReactionBar
className="mt-2"
reactions={comment.reactions ?? []}
canReact={detail.capabilities.reactions === true}
canReact={canReactPullRequestComment(detail, comment)}
subjectId={comment.id}
environmentId={environmentId}
reference={reference}
Expand Down Expand Up @@ -922,7 +923,7 @@ export function PullRequestSummaryTab({
const reactionBar = (
<PullRequestReactionBar
reactions={comment.reactions ?? []}
canReact={detail.capabilities.reactions === true}
canReact={canReactPullRequestComment(detail, comment)}
subjectId={comment.id}
environmentId={environmentId}
reference={reference}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { canEditPullRequestComment } from "./pullRequestEditing.logic";
import { PullRequestMarkdown } from "./PullRequestMarkdown";
import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor";
import { PullRequestReactionBar } from "./PullRequestReactions";
import { canReactPullRequestComment } from "./pullRequestReactions.logic";
import {
PullRequestActorAvatar,
PullRequestDiffStat,
Expand Down Expand Up @@ -619,7 +620,13 @@ export function PullRequestTimelineTab({
stale={isPullRequestVerdictStale(event.at, newestCommitAt)}
cwd={detail.workspaceRoot}
onOpen={openOnHost}
reactions={reactions}
reactions={{
...reactions,
canReact:
event.kind === "review"
? canReactPullRequestComment(detail, { kind: event.kind })
: reactions.canReact,
}}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,57 @@
import type { PullRequestReaction, PullRequestReactionContent } from "@t3tools/contracts";
import type {
PullRequestComment,
PullRequestDetail,
PullRequestReaction,
PullRequestReactionContent,
} from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import {
applyPendingPullRequestReactions,
canReactPullRequestComment,
PULL_REQUEST_REACTION_ORDER,
pullRequestReactionEmoji,
pullRequestReactionName,
pullRequestReactionTooltip,
} from "./pullRequestReactions.logic";

describe("comment reaction permissions", () => {
it.each<{
provider: PullRequestDetail["provider"];
kind: PullRequestComment["kind"];
expected: boolean;
}>([
{ provider: "gitlab", kind: "review", expected: false },
{ provider: "gitlab", kind: "issue-comment", expected: true },
{ provider: "gitlab", kind: "review-comment", expected: true },
{ provider: "github", kind: "review", expected: true },
])("$provider $kind is reactable: $expected", ({ provider, kind, expected }) => {
const capabilities: PullRequestDetail["capabilities"] = {
diff: true,
comment: true,
actions: [],
mergeMethods: [],
search: true,
reactions: true,
review: { inlineComment: true, reply: true, resolve: true, verdicts: [] },
reviewers: { request: true, listCandidates: true },
};
expect(canReactPullRequestComment({ provider, capabilities }, { kind })).toBe(expected);
expect(
canReactPullRequestComment(
{ provider, capabilities: { ...capabilities, reactions: false } },
{ kind },
),
).toBe(false);
expect(
canReactPullRequestComment(
{ provider, capabilities: { ...capabilities, reactions: undefined } },
{ kind },
),
).toBe(false);
});
});

function reaction(overrides: Partial<PullRequestReaction> = {}): PullRequestReaction {
return {
content: "heart",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
import type { PullRequestReaction, PullRequestReactionContent } from "@t3tools/contracts";
import type {
PullRequestComment,
PullRequestDetail,
PullRequestReaction,
PullRequestReactionContent,
} from "@t3tools/contracts";

/** GitLab review rows come from system notes, which cannot receive emoji awards. */
export function canReactPullRequestComment(
detail: Pick<PullRequestDetail, "provider" | "capabilities">,
comment: Pick<PullRequestComment, "kind">,
): boolean {
return (
detail.capabilities.reactions === true &&
!(detail.provider === "gitlab" && comment.kind === "review")
);
}

/** The picker's order, which is GitHub's: the two verdicts first, then the rest as it lists them. */
export const PULL_REQUEST_REACTION_ORDER: ReadonlyArray<PullRequestReactionContent> = [
Expand Down
Loading