mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
feat(desktop): add inline PR diff comments (#2162)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -320,6 +320,8 @@ export function WorkspaceTabs({
|
||||
diff={repoDiff}
|
||||
error={repoDiffError}
|
||||
isLoading={repoDiffLoading}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
pullRequest={selectedPullRequest}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user