feat(desktop): add inline PR diff comments (#2162)

This commit is contained in:
thomaspblock
2026-07-20 12:29:46 +02:00
committed by GitHub
parent de2220a70e
commit 73ac59a033
9 changed files with 570 additions and 34 deletions

View File

@@ -39,11 +39,22 @@ import type {
import { summarizeProjectActivityEvents } from "./projectActivity.mjs";
import type { ProjectIssue } from "./projectIssues.mjs";
import { projectIssueEventsToIssues } from "./projectIssues.mjs";
import type { ProjectPullRequest } from "./projectPullRequests.mjs";
import { projectPullRequestEventsToPullRequests } from "./projectPullRequests.mjs";
import type {
ProjectPullRequest,
ProjectPullRequestCommentAnchor,
} from "./projectPullRequests.mjs";
import {
normalizeProjectPullRequestCommentAnchor,
PR_INLINE_COMMENT_LABEL,
projectPullRequestEventsToPullRequests,
} from "./projectPullRequests.mjs";
import { fetchProjectsWorkItems } from "./projectWorkItems";
export type { ProjectIssue, ProjectPullRequest };
export type {
ProjectIssue,
ProjectPullRequest,
ProjectPullRequestCommentAnchor,
};
const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1";
@@ -391,12 +402,14 @@ async function fetchProjectPullRequests(
// features/pulse/lib/projectComments.ts). If the relay ever allowlists
// 1111, migrate these to NIP-22 comments and drop that filter.
async function createProjectPullRequestComment({
anchor,
content,
mediaTags,
mentionPubkeys = [],
project,
pullRequest,
}: {
anchor?: ProjectPullRequestCommentAnchor;
content: string;
mediaTags?: string[][];
mentionPubkeys?: string[];
@@ -407,6 +420,15 @@ async function createProjectPullRequestComment({
if (!body) {
throw new Error("Comment cannot be empty.");
}
const normalizedAnchor = anchor
? normalizeProjectPullRequestCommentAnchor(anchor)
: null;
if (anchor && !normalizedAnchor) {
throw new Error("Comment location is invalid.");
}
if (normalizedAnchor && !pullRequest.commit) {
throw new Error("Pull request commit is required for inline comments.");
}
const recipients = new Set([
project.owner.toLowerCase(),
@@ -418,6 +440,15 @@ async function createProjectPullRequestComment({
["e", pullRequest.id, "", "root"],
["a", project.repoAddress],
...[...recipients].map((recipient) => ["p", recipient]),
...(normalizedAnchor
? [
["t", PR_INLINE_COMMENT_LABEL],
["c", pullRequest.commit as string],
["file", normalizedAnchor.path],
["side", normalizedAnchor.side],
["line", String(normalizedAnchor.line)],
]
: []),
...(mediaTags ?? []),
];
@@ -826,11 +857,13 @@ export function useCreateProjectPullRequestCommentMutation(
return useMutation({
mutationFn: ({
anchor,
content,
mediaTags,
mentionPubkeys,
pullRequest,
}: {
anchor?: ProjectPullRequestCommentAnchor;
content: string;
mediaTags?: string[][];
mentionPubkeys?: string[];
@@ -838,6 +871,7 @@ export function useCreateProjectPullRequestCommentMutation(
}) => {
if (!project) throw new Error("No project selected.");
return createProjectPullRequestComment({
anchor,
content,
mediaTags,
mentionPubkeys,

View File

@@ -15,6 +15,9 @@ export type ProjectPullRequestComment = {
author: string;
createdAt: number;
commit: string | null;
anchor: ProjectPullRequestCommentAnchor | null;
inlineCommentStatus: "current" | "outdated" | null;
isInlineComment: boolean;
isApproval: boolean;
isChangeRequest: boolean;
isReviewRequest: boolean;
@@ -25,6 +28,12 @@ export type ProjectPullRequestComment = {
reviewerPubkeys: string[];
};
export type ProjectPullRequestCommentAnchor = {
line: number;
path: string;
side: "old" | "new";
};
export type ProjectPullRequestApproval = {
id: string;
author: string;
@@ -44,6 +53,18 @@ export type ProjectPullRequestChangeRequest = {
export const PR_REVIEW_REQUEST_LABEL: string;
export const PR_APPROVAL_LABEL: string;
export const PR_CHANGES_REQUESTED_LABEL: string;
export const PR_INLINE_COMMENT_LABEL: string;
export function normalizeProjectPullRequestCommentAnchor(
anchor:
| {
line?: unknown;
path?: unknown;
side?: unknown;
}
| null
| undefined,
): ProjectPullRequestCommentAnchor | null;
export type ProjectPullRequest = {
id: string;

View File

@@ -148,18 +148,54 @@ function eventToPullRequestUpdate(event) {
export const PR_REVIEW_REQUEST_LABEL = "review-request";
export const PR_APPROVAL_LABEL = "approval";
export const PR_CHANGES_REQUESTED_LABEL = "changes-requested";
export const PR_INLINE_COMMENT_LABEL = "inline-comment";
/** Validate an inline diff anchor without normalizing attacker-controlled paths. */
export function normalizeProjectPullRequestCommentAnchor(anchor) {
if (!anchor || typeof anchor.path !== "string") return null;
const path = anchor.path;
if (
path.length === 0 ||
path.length > 4_096 ||
path.startsWith("/") ||
path.includes("\\") ||
path.includes("\0") ||
path
.split("/")
.some((segment) => !segment || segment === "." || segment === "..")
) {
return null;
}
if (anchor.side !== "old" && anchor.side !== "new") return null;
if (!Number.isSafeInteger(anchor.line) || anchor.line < 1) return null;
return { line: anchor.line, path, side: anchor.side };
}
function eventToPullRequestComment(event) {
const labels = getAllTags(event, "t").map((label) => label.toLowerCase());
const isReviewRequest = labels.includes(PR_REVIEW_REQUEST_LABEL);
const isApproval = labels.includes(PR_APPROVAL_LABEL);
const isChangeRequest = labels.includes(PR_CHANGES_REQUESTED_LABEL);
const lineTag = getTag(event, "line");
const parsedLine =
lineTag && /^[1-9]\d*$/.test(lineTag) ? Number(lineTag) : Number.NaN;
const anchor =
isReviewRequest || isApproval || isChangeRequest
? null
: normalizeProjectPullRequestCommentAnchor({
line: parsedLine,
path: getTag(event, "file"),
side: getTag(event, "side"),
});
return {
id: event.id,
content: event.content,
author: event.pubkey,
createdAt: event.created_at,
commit: getTag(event, "c") ?? null,
anchor,
isInlineComment:
Boolean(anchor) || labels.includes(PR_INLINE_COMMENT_LABEL),
isApproval,
isChangeRequest,
isReviewRequest,
@@ -281,6 +317,12 @@ export function eventToProjectPullRequest(
const initialCommit = getTag(pullRequest, "c") ?? null;
const comments = parsedComments.map((comment) => ({
...comment,
inlineCommentStatus: comment.anchor
? latestCommit &&
reviewDecisionCommit(comment, initialCommit) === latestCommit
? "current"
: "outdated"
: null,
isTrustedReviewDecision:
Boolean(comment.reviewDecision) &&
trustedActors.has(comment.author.toLowerCase()),

View File

@@ -179,6 +179,7 @@ function commentEvent({
labels = [],
reviewers = [],
commit,
anchor,
}) {
return {
id: id ?? `comment-${pubkey.slice(0, 8)}-${createdAt}`,
@@ -192,10 +193,89 @@ function commentEvent({
...reviewers.map((reviewer) => ["p", reviewer]),
...labels.map((label) => ["t", label]),
...(commit ? [["c", commit]] : []),
...(anchor
? [
["t", "inline-comment"],
["file", anchor.path],
["side", anchor.side],
["line", String(anchor.line)],
]
: []),
],
};
}
test("parses commit-scoped inline comment anchors", () => {
const commit = "1111111111111111111111111111111111111111";
const comment = commentEvent({
pubkey: ATTACKER,
createdAt: 150,
content: "Could this be clearer?",
commit,
anchor: {
path: "desktop/src/features/projects/hooks.ts",
side: "new",
line: 42,
},
});
const pullRequest = eventToProjectPullRequest(
pullRequestEvent(),
[],
[comment],
);
assert.deepEqual(pullRequest.comments[0].anchor, {
path: "desktop/src/features/projects/hooks.ts",
side: "new",
line: 42,
});
assert.equal(pullRequest.comments[0].inlineCommentStatus, "current");
assert.equal(pullRequest.comments[0].isInlineComment, true);
});
test("marks inline comments on earlier commits as outdated", () => {
const initialCommit = "1111111111111111111111111111111111111111";
const currentCommit = "2222222222222222222222222222222222222222";
const comment = commentEvent({
pubkey: ATTACKER,
createdAt: 150,
commit: initialCommit,
anchor: { path: "src/example.ts", side: "old", line: 7 },
});
const update = updateEvent({
pubkey: AUTHOR,
createdAt: 200,
commit: currentCommit,
});
const pullRequest = eventToProjectPullRequest(
pullRequestEvent(),
[update],
[comment],
);
assert.equal(pullRequest.comments[0].inlineCommentStatus, "outdated");
});
test("rejects malformed inline comment anchors", () => {
const comment = commentEvent({
pubkey: ATTACKER,
createdAt: 150,
commit: "1111111111111111111111111111111111111111",
anchor: { path: "../secrets", side: "new", line: 0 },
});
const pullRequest = eventToProjectPullRequest(
pullRequestEvent(),
[],
[comment],
);
assert.equal(pullRequest.comments[0].anchor, null);
assert.equal(pullRequest.comments[0].inlineCommentStatus, null);
});
test("draft/ready toggles via status kinds 1633 and 1630", () => {
const draft = statusEvent({ kind: 1633, pubkey: AUTHOR, createdAt: 300 });
const draftPullRequest = eventToProjectPullRequest(

View File

@@ -17,6 +17,7 @@ import {
FileVideo,
FolderGit2,
GitCommitHorizontal,
MessageSquarePlus,
Package,
Search,
Settings,
@@ -24,10 +25,19 @@ import {
type LucideIcon,
} from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import type { ProjectPullRequest } from "@/features/projects/hooks";
import {
type Project,
type ProjectPullRequest,
type ProjectPullRequestCommentAnchor,
useCreateProjectPullRequestCommentMutation,
} from "@/features/projects/hooks";
import type { ProjectPullRequestComment } from "@/features/projects/projectPullRequests.mjs";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { cn } from "@/shared/lib/cn";
import type { ProjectRepoDiff, ProjectRepoDiffFile } from "@/shared/api/types";
import { ProjectPullRequestInlineCommentThread } from "./ProjectPullRequestInlineComments";
function fileName(path: string) {
return path.split("/").pop() || path;
@@ -47,6 +57,21 @@ type DiffRow = {
type: "add" | "context" | "delete" | "hunk";
};
type InlineCommentControls = {
activeAnchor: ProjectPullRequestCommentAnchor | null;
comments: ProjectPullRequestComment[];
isSending: boolean;
onCancel: () => void;
onStart: (anchor: ProjectPullRequestCommentAnchor) => void;
onSubmit: (
anchor: ProjectPullRequestCommentAnchor,
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => Promise<unknown>;
profiles?: UserProfileLookup;
};
type FileTreeNode = {
children: Map<string, FileTreeNode>;
file: ProjectRepoDiffFile | null;
@@ -366,6 +391,29 @@ function linePrefix(type: DiffRow["type"]) {
return " ";
}
function commentAnchorForRow(
file: ProjectRepoDiffFile,
row: DiffRow,
): ProjectPullRequestCommentAnchor | null {
if (row.type === "hunk") return null;
const side = row.type === "delete" ? "old" : "new";
const line = side === "old" ? row.oldLine : row.newLine;
return line ? { line, path: file.path, side } : null;
}
function anchorsEqual(
left: ProjectPullRequestCommentAnchor | null,
right: ProjectPullRequestCommentAnchor | null,
) {
return Boolean(
left &&
right &&
left.line === right.line &&
left.path === right.path &&
left.side === right.side,
);
}
function fileAdditions(file: ProjectRepoDiffFile) {
return file.additions;
}
@@ -390,7 +438,13 @@ function errorMessage(error: unknown) {
return message.replace(/(^|[\s'"`])(?:[A-Za-z]:)?[\\/][^\s'"`]+/g, "$1…");
}
function DiffPreview({ file }: { file: ProjectRepoDiffFile }) {
function DiffPreview({
file,
inlineComments,
}: {
file: ProjectRepoDiffFile;
inlineComments?: InlineCommentControls;
}) {
const rows = diffRows(file);
if (rows.length === 0) {
return (
@@ -408,34 +462,87 @@ function DiffPreview({ file }: { file: ProjectRepoDiffFile }) {
local checkout to review the full change.
</div>
) : null}
{rows.map((row) => (
<div
className={cn(
"grid min-h-5 grid-cols-[3rem_3rem_1.5rem_minmax(0,1fr)]",
diffLineClassName(row.type),
)}
key={row.key}
>
<span className="select-none border-border/40 border-r px-2 text-right text-muted-foreground/70">
{row.oldLine ?? " "}
</span>
<span className="select-none border-border/40 border-r px-2 text-right text-muted-foreground/70">
{row.newLine ?? " "}
</span>
<span
className={cn(
"select-none px-2",
row.type === "add" && "text-green-500",
row.type === "delete" && "text-destructive",
)}
>
{linePrefix(row.type)}
</span>
<code className="min-w-0 whitespace-pre pr-3 text-foreground">
{row.content || " "}
</code>
</div>
))}
{rows.map((row) => {
const anchor = commentAnchorForRow(file, row);
const comments =
anchor && inlineComments
? inlineComments.comments.filter(
(comment) =>
comment.anchor && anchorsEqual(comment.anchor, anchor),
)
: [];
const isActive = anchorsEqual(
inlineComments?.activeAnchor ?? null,
anchor,
);
return (
<React.Fragment key={row.key}>
<div
className={cn(
"group grid min-h-5 grid-cols-[3rem_3rem_2rem_1.5rem_minmax(0,1fr)]",
diffLineClassName(row.type),
)}
data-line={anchor?.line}
data-path={anchor?.path}
data-side={anchor?.side}
data-testid={anchor ? "project-diff-line" : undefined}
>
<span className="select-none border-border/40 border-r px-2 text-right text-muted-foreground/70">
{row.oldLine ?? " "}
</span>
<span className="select-none border-border/40 border-r px-2 text-right text-muted-foreground/70">
{row.newLine ?? " "}
</span>
<span className="flex select-none items-center justify-center">
{anchor && inlineComments ? (
<button
aria-label={`Comment on ${anchor.path} ${anchor.side} line ${anchor.line}`}
className={cn(
"flex h-5 w-5 items-center justify-center rounded text-muted-foreground opacity-0 hover:bg-primary hover:text-primary-foreground focus-visible:opacity-100 focus-visible:outline-hidden group-hover:opacity-100",
(comments.length > 0 || isActive) && "opacity-100",
)}
data-testid="project-diff-add-comment"
onClick={() => inlineComments.onStart(anchor)}
title="Add line comment"
type="button"
>
<MessageSquarePlus className="h-3.5 w-3.5" />
</button>
) : null}
</span>
<span
className={cn(
"select-none px-2",
row.type === "add" && "text-green-500",
row.type === "delete" && "text-destructive",
)}
>
{linePrefix(row.type)}
</span>
<code className="min-w-0 whitespace-pre pr-3 text-foreground">
{row.content || " "}
</code>
</div>
{anchor && inlineComments ? (
<ProjectPullRequestInlineCommentThread
activeAnchor={isActive ? anchor : null}
comments={comments}
isSending={inlineComments.isSending}
onCancel={inlineComments.onCancel}
onSubmit={(content, mentionPubkeys, mediaTags) =>
inlineComments.onSubmit(
anchor,
content,
mentionPubkeys,
mediaTags,
)
}
profiles={inlineComments.profiles}
/>
) : null}
</React.Fragment>
);
})}
</div>
);
}
@@ -494,13 +601,59 @@ export function ProjectPullRequestFilesChangedPanel({
error,
diff,
isLoading,
profiles,
project,
pullRequest,
}: {
error: unknown;
diff: ProjectRepoDiff | null | undefined;
isLoading: boolean;
profiles?: UserProfileLookup;
project: Project;
pullRequest: ProjectPullRequest | null;
}) {
const [activeAnchor, setActiveAnchor] =
React.useState<ProjectPullRequestCommentAnchor | null>(null);
const { isPending: isPostingComment, mutateAsync: postComment } =
useCreateProjectPullRequestCommentMutation(project);
const inlineComments = React.useMemo(
() =>
pullRequest?.comments.filter(
(comment) =>
comment.anchor && comment.inlineCommentStatus === "current",
) ?? [],
[pullRequest],
);
const handleSubmit = React.useCallback(
async (
anchor: ProjectPullRequestCommentAnchor,
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => {
if (!pullRequest) throw new Error("No pull request selected.");
try {
await postComment({
anchor,
content,
mediaTags,
mentionPubkeys,
pullRequest,
});
setActiveAnchor(null);
toast.success("Line comment posted.");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to post line comment.",
);
throw error;
}
},
[postComment, pullRequest],
);
return (
<ProjectDiffFilesPanel
diff={pullRequest ? diff : null}
@@ -511,6 +664,19 @@ export function ProjectPullRequestFilesChangedPanel({
? `${pullRequest.title} · ${pullRequest.commit?.slice(0, 7) ?? "PR"}`
: ""
}
inlineComments={
pullRequest
? {
activeAnchor,
comments: inlineComments,
isSending: isPostingComment,
onCancel: () => setActiveAnchor(null),
onStart: setActiveAnchor,
onSubmit: handleSubmit,
profiles,
}
: undefined
}
isLoading={isLoading}
subjectLabel="pull request"
/>
@@ -523,6 +689,7 @@ export function ProjectDiffFilesPanel({
isLoading,
embedded = false,
headerLabel,
inlineComments,
subjectLabel,
}: {
error: unknown;
@@ -531,6 +698,7 @@ export function ProjectDiffFilesPanel({
/** Render without an outer border, for nesting inside an existing card. */
embedded?: boolean;
headerLabel: string;
inlineComments?: InlineCommentControls;
subjectLabel: string;
}) {
const outerBorderClass = embedded
@@ -684,7 +852,10 @@ export function ProjectDiffFilesPanel({
</span>
</div>
</header>
<DiffPreview file={selectedFile} />
<DiffPreview
file={selectedFile}
inlineComments={inlineComments}
/>
</article>
) : (
<div className="border border-border/60 bg-background/45 p-4 text-sm text-muted-foreground">

View File

@@ -0,0 +1,106 @@
import { FileCode2, MessageSquareText } from "lucide-react";
import { ForumComposer } from "@/features/forum/ui/ForumComposer";
import type {
ProjectPullRequestComment,
ProjectPullRequestCommentAnchor,
} from "@/features/projects/projectPullRequests.mjs";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { Markdown } from "@/shared/ui/markdown";
function commentAuthor(
pubkey: string,
profiles: UserProfileLookup | undefined,
) {
const profile = profiles?.[normalizePubkey(pubkey)];
return (
profile?.displayName?.trim() ||
profile?.nip05Handle?.trim() ||
truncatePubkey(pubkey)
);
}
function commentDate(createdAt: number) {
return new Date(createdAt * 1_000).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
});
}
export function ProjectPullRequestInlineCommentThread({
activeAnchor,
comments,
isSending,
onCancel,
onSubmit,
profiles,
}: {
activeAnchor: ProjectPullRequestCommentAnchor | null;
comments: ProjectPullRequestComment[];
isSending: boolean;
onCancel: () => void;
onSubmit: (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => Promise<unknown>;
profiles?: UserProfileLookup;
}) {
if (comments.length === 0 && !activeAnchor) return null;
return (
<div
className="border-border/50 border-y bg-background px-3 py-2 font-sans"
data-testid="project-inline-comment-thread"
>
{comments.length > 0 ? (
<div className="divide-y divide-border/50 rounded-lg border border-border/60 bg-card">
{comments.map((comment) => (
<article
className="space-y-1.5 px-3 py-2.5"
data-testid="project-inline-comment"
key={comment.id}
>
<div className="flex min-w-0 items-center gap-2 text-xs">
<MessageSquareText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
{commentAuthor(comment.author, profiles)}
</span>
<span className="shrink-0 text-muted-foreground">
{commentDate(comment.createdAt)}
</span>
</div>
<Markdown
className="text-sm"
content={comment.content}
interactive={false}
/>
</article>
))}
</div>
) : null}
{activeAnchor ? (
<ForumComposer
className="mt-2 border border-border/60 bg-background/70"
disabled={isSending}
header={
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<FileCode2 className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{activeAnchor.path}</span>
<span className="shrink-0">
{activeAnchor.side === "new" ? "+" : "-"}
{activeAnchor.line}
</span>
</div>
}
isSending={isSending}
onCancel={onCancel}
onSubmit={onSubmit}
placeholder="Leave a comment on this line…"
profiles={profiles}
/>
) : null}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import {
Check,
FileCode2,
GitBranch,
GitCommitHorizontal,
GitMerge,
@@ -630,6 +631,21 @@ function PullRequestDetail({
}
return (
<article className="py-3" key={item.id}>
{item.anchor ? (
<div className="mb-2 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<FileCode2 className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{item.anchor.path}</span>
<span className="shrink-0 font-mono">
{item.anchor.side === "new" ? "+" : "-"}
{item.anchor.line}
</span>
{item.inlineCommentStatus === "outdated" ? (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-2xs">
Outdated
</span>
) : null}
</div>
) : null}
<div className="mb-2">
<AuthorIdentity
profiles={profiles}

View File

@@ -320,6 +320,8 @@ export function WorkspaceTabs({
diff={repoDiff}
error={repoDiffError}
isLoading={repoDiffLoading}
profiles={profiles}
project={project}
pullRequest={selectedPullRequest}
/>
</TabsContent>

View File

@@ -267,6 +267,70 @@ test("PR creator/owner can toggle draft, request reviews, and approve", async ({
});
});
test("reviewer can leave a commit-scoped inline diff comment", async ({
page,
}) => {
await enableProjectsFeature(page);
await installMockBridge(page);
await openBuzzProject(page);
await page.getByRole("tab", { name: "Pull Request" }).click();
const aliceRow = page
.getByTestId("project-pull-request-row")
.filter({ hasText: "alice" })
.first();
await aliceRow.getByRole("button", { name: /^#/ }).click();
await page.getByRole("tab", { name: /Files changed/ }).click();
const diffLine = page
.getByTestId("project-diff-line")
.filter({ hasText: "function CommunityTabs({ selectedCommitHash })" });
await expect(diffLine).toBeVisible({ timeout: 10_000 });
await diffLine.hover();
await diffLine.getByTestId("project-diff-add-comment").click();
const composer = page.getByTestId("project-inline-comment-thread");
await composer
.locator("[contenteditable='true']")
.fill("Please add a type for this parameter.");
await composer.getByRole("button", { name: "Send message" }).click();
await expect(page.getByText("Line comment posted.")).toBeVisible();
await expect
.poll(() =>
page.evaluate(() =>
window.__BUZZ_E2E_SIGNED_EVENTS__?.find(
(event) => event.content === "Please add a type for this parameter.",
),
),
)
.not.toBeUndefined();
const inlineCommentEvent = await page.evaluate(() =>
window.__BUZZ_E2E_SIGNED_EVENTS__?.find(
(event) => event.content === "Please add a type for this parameter.",
),
);
expect(inlineCommentEvent?.tags).toContainEqual(["t", "inline-comment"]);
expect(inlineCommentEvent?.tags).toContainEqual(["c", expect.any(String)]);
expect(inlineCommentEvent?.tags).toContainEqual([
"file",
"desktop/src/features/projects/ui/ProjectDetailScreen.tsx",
]);
expect(inlineCommentEvent?.tags).toContainEqual(["side", "new"]);
expect(inlineCommentEvent?.tags).toContainEqual(["line", "3"]);
await expect(page.getByTestId("project-inline-comment")).toContainText(
"Please add a type for this parameter.",
);
await page.getByRole("tab", { name: "Conversation" }).click();
await expect(
page.getByText("Please add a type for this parameter."),
).toBeVisible();
await expect(
page.getByText("desktop/src/features/projects/ui/ProjectDetailScreen.tsx"),
).toBeVisible();
});
test("managed agent repository owner can merge", async ({ page }) => {
await enableProjectsFeature(page);
await page.addInitScript((owner) => {