diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index a6e4bc2..72c7166 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -3,27 +3,19 @@ name: PR Checks on: pull_request: types: [opened, synchronize, reopened] - pull_request_target: - types: [opened, synchronize, reopened] concurrency: - group: pr-checks-${{ github.event.pull_request.number || github.ref }} + group: pr-checks-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: lint: name: Lint runs-on: ubuntu-latest - # Only run once: pull_request for same-repo PRs, pull_request_target for forks - if: >- - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository) steps: - name: Checkout uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -45,15 +37,10 @@ jobs: typecheck: name: Type Check runs-on: ubuntu-latest - if: >- - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || - (github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository) steps: - name: Checkout uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - name: Setup pnpm uses: pnpm/action-setup@v4 diff --git a/apps/dashboard/src/components/details/comment-reply-form.tsx b/apps/dashboard/src/components/details/comment-reply-form.tsx new file mode 100644 index 0000000..0281525 --- /dev/null +++ b/apps/dashboard/src/components/details/comment-reply-form.tsx @@ -0,0 +1,114 @@ +import { CommentIcon } from "@diffkit/icons"; +import { MarkdownEditor } from "@diffkit/ui/components/markdown-editor"; +import { toast } from "@diffkit/ui/components/sonner"; +import { Spinner } from "@diffkit/ui/components/spinner"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; +import { createComment } from "#/lib/github.functions"; +import { githubQueryKeys } from "#/lib/github.query"; +import { checkPermissionWarning } from "#/lib/warning-store"; + +export function CommentReplyForm({ + owner, + repo, + issueNumber, + parentAuthor, + parentBody, + parentCommentId, + onClose, +}: { + owner: string; + repo: string; + issueNumber: number; + parentAuthor: string; + parentBody: string; + parentCommentId: number; + onClose: () => void; +}) { + const [value, setValue] = useState(""); + const [isSending, setIsSending] = useState(false); + const queryClient = useQueryClient(); + + const handleSend = useCallback(async () => { + if (!value.trim()) return; + setIsSending(true); + + const firstLines = parentBody + .split("\n") + .slice(0, 3) + .map((l) => `> ${l}`) + .join("\n"); + const quoteBlock = `> **@${parentAuthor}** [commented](#issuecomment-${parentCommentId}):\n${firstLines}\n\n`; + + try { + const result = await createComment({ + data: { + owner, + repo, + issueNumber, + body: quoteBlock + value.trim(), + }, + }); + if (result.ok) { + setValue(""); + onClose(); + void queryClient.invalidateQueries({ + queryKey: githubQueryKeys.all, + }); + } else { + toast.error(result.error); + checkPermissionWarning(result, `${owner}/${repo}`); + } + } catch { + toast.error("Failed to send reply"); + } finally { + setIsSending(false); + } + }, [ + value, + parentAuthor, + parentBody, + parentCommentId, + owner, + repo, + issueNumber, + onClose, + queryClient, + ]); + + return ( +
+ +
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/components/details/comment-threads.ts b/apps/dashboard/src/components/details/comment-threads.ts new file mode 100644 index 0000000..7522dcf --- /dev/null +++ b/apps/dashboard/src/components/details/comment-threads.ts @@ -0,0 +1,78 @@ +type CommentLike = { + id: number; + body: string; + author: { login: string } | null; +}; + +/** + * Detects reply relationships between comments by matching blockquote + * patterns: `> **@user** [commented](#issuecomment-ID):` or + * simple `> @user` prefix with quoted text matching a prior comment. + */ +export function buildCommentThreads( + comments: T[], +): { + repliesByCommentId: Map; + replyIds: Set; +} { + const repliesByCommentId = new Map(); + const replyIds = new Set(); + const commentById = new Map(); + + for (const c of comments) { + commentById.set(c.id, c); + } + + for (const comment of comments) { + const body = comment.body; + if (!body.startsWith(">")) continue; + + // Match our reply format: > **@user** [commented](#issuecomment-ID): + const linkMatch = body.match( + /^>\s*\*?\*?@(\S+)\*?\*?\s*\[commented\]\(#issuecomment-(\d+)\)/, + ); + if (linkMatch) { + const parentId = Number(linkMatch[2]); + if (commentById.has(parentId)) { + const existing = repliesByCommentId.get(parentId) ?? []; + existing.push(comment); + repliesByCommentId.set(parentId, existing); + replyIds.add(comment.id); + continue; + } + } + + // Fallback: match `> @user wrote:` or `> @user` pattern then fuzzy-match quoted text + const simpleMatch = body.match(/^>\s*@(\S+)/); + if (!simpleMatch) continue; + + const quotedAuthor = simpleMatch[1].replace(/[*:]/g, ""); + const quotedLines = body + .split("\n") + .filter((l) => l.startsWith("> ")) + .map((l) => l.slice(2).trim()) + .filter((l) => !l.startsWith("**@") && !l.startsWith("@")); + + if (quotedLines.length === 0) continue; + + const quotedSnippet = quotedLines[0].slice(0, 60); + if (quotedSnippet.length < 10) continue; + + // Walk backwards looking for a matching parent + for (let i = comments.indexOf(comment) - 1; i >= 0; i--) { + const candidate = comments[i]; + if ( + candidate.author?.login === quotedAuthor && + candidate.body.includes(quotedSnippet) + ) { + const existing = repliesByCommentId.get(candidate.id) ?? []; + existing.push(comment); + repliesByCommentId.set(candidate.id, existing); + replyIds.add(comment.id); + break; + } + } + } + + return { repliesByCommentId, replyIds }; +} diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx index 108b267..960c9b7 100644 --- a/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx +++ b/apps/dashboard/src/components/issues/detail/issue-detail-activity.tsx @@ -1,6 +1,7 @@ import { ChevronDownIcon, CircleIcon, + CommentIcon, EditIcon, GitPullRequestIcon, IssuesIcon, @@ -10,8 +11,10 @@ import { import { Markdown } from "@diffkit/ui/components/markdown"; import { cn } from "@diffkit/ui/lib/utils"; import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { CommentMoreMenu } from "#/components/details/comment-more-menu"; +import { CommentReplyForm } from "#/components/details/comment-reply-form"; +import { buildCommentThreads } from "#/components/details/comment-threads"; import { DetailActivityHeader, DetailCommentBox, @@ -267,13 +270,20 @@ export function IssueDetailActivitySection({ issueAuthor: GitHubActor | null; viewerLogin?: string; }) { + const { repliesByCommentId, replyIds } = useMemo( + () => buildCommentThreads(comments ?? []), + [comments], + ); + const allItems = groupTimelineEvents( [ - ...(comments ?? []).map((comment) => ({ - type: "comment" as const, - date: comment.createdAt, - data: comment, - })), + ...(comments ?? []) + .filter((c) => !replyIds.has(c.id)) + .map((comment) => ({ + type: "comment" as const, + date: comment.createdAt, + data: comment, + })), ...(events ?? []).map((event) => ({ type: "event" as const, date: event.createdAt, @@ -362,49 +372,18 @@ export function IssueDetailActivitySection({ const row = (() => { if (item.type === "comment") { const comment = item.data; + const replies = repliesByCommentId.get(comment.id); return ( -
-
- {comment.author ? ( - {comment.author.login} - ) : ( -
- )} - - {comment.author?.login ?? "Unknown"} - - - {formatRelativeTime(comment.createdAt)} - -
- -
-
- - {comment.body} - -
+ comment={comment} + replies={replies} + isFirst={index === 0} + owner={owner} + repo={repo} + issueNumber={issueNumber} + viewerLogin={viewerLogin} + /> ); } @@ -546,6 +525,154 @@ export function IssueDetailActivitySection({ ); } +function IssueCommentBubble({ + comment, + owner, + repo, + issueNumber, + viewerLogin, + onReply, + isReply, +}: { + comment: IssueComment; + owner: string; + repo: string; + issueNumber: number; + viewerLogin?: string; + onReply?: () => void; + isReply?: boolean; +}) { + return ( +
+
+ {comment.author ? ( + {comment.author.login} + ) : ( +
+ )} + + {comment.author?.login ?? "Unknown"} + + + {formatRelativeTime(comment.createdAt)} + +
+ {onReply && ( + + )} + +
+
+ {comment.body} +
+ ); +} + +function IssueCommentWithThread({ + comment, + replies, + isFirst, + owner, + repo, + issueNumber, + viewerLogin, +}: { + comment: IssueComment; + replies?: IssueComment[]; + isFirst: boolean; + owner: string; + repo: string; + issueNumber: number; + viewerLogin?: string; +}) { + const [showReplyForm, setShowReplyForm] = useState(false); + const hasReplies = replies && replies.length > 0; + + return ( +
+ setShowReplyForm(true)} + /> + + {hasReplies && ( +
+ {replies.map((reply) => ( + setShowReplyForm(true)} + isReply + /> + ))} +
+ )} + + {showReplyForm && ( +
+ setShowReplyForm(false)} + /> +
+ )} +
+ ); +} + function getInvolvedUsers( issueAuthor: GitHubActor | null, comments?: IssueComment[], diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx index 5518fce..2513974 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx @@ -50,6 +50,8 @@ import { useState, } from "react"; import { CommentMoreMenu } from "#/components/details/comment-more-menu"; +import { CommentReplyForm } from "#/components/details/comment-reply-form"; +import { buildCommentThreads } from "#/components/details/comment-threads"; import { DetailActivityHeader, DetailCommentBox, @@ -1506,13 +1508,21 @@ function ActivityTimeline({ } return { reviewCommentsByReviewId: byReview, repliesByCommentId: replies }; }, [reviewComments]); + + const { + repliesByCommentId: issueCommentReplies, + replyIds: issueCommentReplyIds, + } = useMemo(() => buildCommentThreads(comments), [comments]); + const allItems = groupTimelineEvents( [ - ...comments.map((comment) => ({ - type: "comment" as const, - date: comment.createdAt, - data: comment, - })), + ...comments + .filter((c) => !issueCommentReplyIds.has(c.id)) + .map((comment) => ({ + type: "comment" as const, + date: comment.createdAt, + data: comment, + })), ...commits.map((commit) => ({ type: "commit" as const, date: commit.createdAt, @@ -1575,49 +1585,18 @@ function ActivityTimeline({ const row = (() => { if (item.type === "comment") { const comment = item.data; + const commentReplies = issueCommentReplies.get(comment.id); return ( -
-
- {comment.author ? ( - {comment.author.login} - ) : ( -
- )} - - {comment.author?.login ?? "Unknown"} - - - {formatRelativeTime(comment.createdAt)} - -
- -
-
- - {comment.body} - -
+ comment={comment} + replies={commentReplies} + isFirst={index === 0} + owner={owner} + repo={repo} + pullNumber={pullNumber} + viewerLogin={viewerLogin} + /> ); } @@ -2064,6 +2043,154 @@ function trimDiffHunk( return [newHeader, ...trimmed].join("\n"); } +function PullCommentBubble({ + comment, + owner, + repo, + pullNumber, + viewerLogin, + onReply, + isReply, +}: { + comment: PullComment; + owner: string; + repo: string; + pullNumber: number; + viewerLogin?: string; + onReply?: () => void; + isReply?: boolean; +}) { + return ( +
+
+ {comment.author ? ( + {comment.author.login} + ) : ( +
+ )} + + {comment.author?.login ?? "Unknown"} + + + {formatRelativeTime(comment.createdAt)} + +
+ {onReply && ( + + )} + +
+
+ {comment.body} +
+ ); +} + +function PullCommentWithThread({ + comment, + replies, + isFirst, + owner, + repo, + pullNumber, + viewerLogin, +}: { + comment: PullComment; + replies?: PullComment[]; + isFirst: boolean; + owner: string; + repo: string; + pullNumber: number; + viewerLogin?: string; +}) { + const [showReplyForm, setShowReplyForm] = useState(false); + const hasReplies = replies && replies.length > 0; + + return ( +
+ setShowReplyForm(true)} + /> + + {hasReplies && ( +
+ {replies.map((reply) => ( + setShowReplyForm(true)} + isReply + /> + ))} +
+ )} + + {showReplyForm && ( +
+ setShowReplyForm(false)} + /> +
+ )} +
+ ); +} + function ReviewCommentBubble({ comment, owner, diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 469cf8f..4977474 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -2827,7 +2827,8 @@ function mapTimelineEvents(rawEvents: unknown[]): TimelineEvent[] { return { id: (raw.id as number) ?? 0, event: raw.event as string, - createdAt: (raw.created_at as string) ?? "", + createdAt: + (raw.created_at as string) ?? (raw.submitted_at as string) ?? "", actor: actor ? { login: (actor.login as string) ?? "", @@ -3130,7 +3131,18 @@ async function computePullStatus( }); } - const checkRuns = checksResponse?.data.check_runs ?? []; + const allCheckRuns = checksResponse?.data.check_runs ?? []; + + // Deduplicate by name — keep the most recent run (highest id) per check name + const latestByName = new Map(); + for (const check of allCheckRuns) { + const existing = latestByName.get(check.name); + if (!existing || check.id > existing.id) { + latestByName.set(check.name, check); + } + } + const checkRuns = Array.from(latestByName.values()); + let passed = 0; let failed = 0; let pending = 0;