diff --git a/src/components/git/CommitList.tsx b/src/components/git/CommitList.tsx index 473c6a0..bb11a6b 100644 --- a/src/components/git/CommitList.tsx +++ b/src/components/git/CommitList.tsx @@ -5,6 +5,12 @@ import { cn } from "~/lib/utils"; /** Visible strip rows before the list scrolls (`h-10` per row → 12.5rem total). */ const STRIP_VISIBLE_ROWS = 5; +export function sortCommitsNewestFirst(commits: GitCommit[]): GitCommit[] { + return [...commits].sort( + (a, b) => b.committedAt.getTime() - a.committedAt.getTime() + ); +} + export function CommitList({ commits, selectedSha, diff --git a/src/components/git/DiffFileTree.tsx b/src/components/git/DiffFileTree.tsx index f4ca3af..a619ca4 100644 --- a/src/components/git/DiffFileTree.tsx +++ b/src/components/git/DiffFileTree.tsx @@ -34,7 +34,7 @@ export function DiffFileTree({ paths, gitStatus, flattenEmptyDirectories: true, - initialExpansion: 1, + initialExpansion: "open", icons: "standard", density: "compact", initialSelectedPaths: activePath ? [activePath] : [], diff --git a/src/components/git/GitHubMarkdownBody.tsx b/src/components/git/GitHubMarkdownBody.tsx index 4c9d107..880ad2e 100644 --- a/src/components/git/GitHubMarkdownBody.tsx +++ b/src/components/git/GitHubMarkdownBody.tsx @@ -113,16 +113,42 @@ const markdownComponents: Components = { td: ({ children }) => ( {children} ), - details: ({ children }) => ( -
- {children} -
- ), - summary: ({ children }) => ( - - {children} - - ), + details: ({ className, children, ...props }) => { + const isAddressed = String(className ?? "").includes( + "gh-bot-finding-addressed" + ); + return ( +
+ {children} +
+ ); + }, + summary: ({ className, children, ...props }) => { + const isAddressed = String(className ?? "").includes( + "gh-bot-finding-addressed" + ); + return ( + + {children} + + ); + }, hr: () =>
, ul: ({ children }) => ( @@ -151,13 +177,22 @@ export function GitHubMarkdownBody({ body, className, collapsible = true, + collapseAddressedFindings = false, }: { body: string; className?: string; collapsible?: boolean; + /** Wrap CodeRabbit "Addressed" finding blocks in closed <details>. */ + collapseAddressedFindings?: boolean; }) { const [expanded, setExpanded] = useState(false); - const processed = useMemo(() => preprocessGitHubMarkdown(body), [body]); + const processed = useMemo( + () => + preprocessGitHubMarkdown(body, { + collapseAddressedFindings, + }), + [body, collapseAddressedFindings] + ); const hasNestedCollapsibles = useMemo( () => hasGitHubCollapsibleSections(processed), [processed] diff --git a/src/components/git/PrReviewFeedItem.tsx b/src/components/git/PrReviewFeedItem.tsx index df0c01d..8dfb03f 100644 --- a/src/components/git/PrReviewFeedItem.tsx +++ b/src/components/git/PrReviewFeedItem.tsx @@ -1,11 +1,14 @@ import type { ReactNode } from "react"; import { + AlertCircle, Check, GitPullRequest, MessageSquare, MessageSquarePlus, X, } from "lucide-react"; +import { summarizeBotReviewIssueComment } from "~/lib/git/bot-review-comment-summary"; +import { Badge } from "~/components/ui/badge"; import { DiffLineLocationLink } from "~/components/git/DiffLineLocationLink"; import { GitHubMarkdownBody } from "~/components/git/GitHubMarkdownBody"; import { PrReviewFeedAttachedComments } from "~/components/git/PrReviewFeedAttachedComments"; @@ -198,8 +201,18 @@ export function PrReviewFeedItemIssueComment({ const body = comment.body?.trim() ?? ""; if (!body) return null; + const botSummary = summarizeBotReviewIssueComment(comment); + return ( - + - Bot - - ) : null + + {botSummary ? ( + + {botSummary.needsAction ? "Needs action" : "Addressed"} + + ) : null} + {comment.isBot ? ( + + Bot + + ) : null} + } /> + {botSummary ? ( +
+ {botSummary.needsAction ? ( + + ) : ( + + )} + + {botSummary.label} + {botSummary.actionableCount != null ? ( + + {botSummary.addressedCount} of {botSummary.actionableCount}{" "} + marked addressed on GitHub + + ) : null} + +
+ ) : null}
- +
); diff --git a/src/components/git/TaskDevelopmentWorkspace.tsx b/src/components/git/TaskDevelopmentWorkspace.tsx index 7f6a1d4..c0d1bdd 100644 --- a/src/components/git/TaskDevelopmentWorkspace.tsx +++ b/src/components/git/TaskDevelopmentWorkspace.tsx @@ -1,6 +1,9 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { PhaseBanner } from "~/components/git/PhaseBanner"; -import { CommitList } from "~/components/git/CommitList"; +import { + CommitList, + sortCommitsNewestFirst, +} from "~/components/git/CommitList"; import { DiffViewer } from "~/components/git/DiffViewer"; import { DiffReviewPanel } from "~/components/git/DiffReviewPanel"; import { DiffLoadingSkeleton } from "~/components/git/review/DiffLoadingSkeleton"; @@ -92,9 +95,16 @@ export function TaskDevelopmentWorkspace({ usePrCommitsList ); - const commits = usePrCommitsList - ? (prCommitsQuery.data?.commits ?? []) - : (branchCommitsQuery.data?.commits ?? []); + const commits = useMemo(() => { + const raw = usePrCommitsList + ? (prCommitsQuery.data?.commits ?? []) + : (branchCommitsQuery.data?.commits ?? []); + return sortCommitsNewestFirst(raw); + }, [ + usePrCommitsList, + prCommitsQuery.data?.commits, + branchCommitsQuery.data?.commits, + ]); const prDiffQuery = useQuery({ queryKey: ["git", "diff", taskId, "pr", reviewPr?.id], diff --git a/src/components/git/TaskPullRequestReviewFeed.tsx b/src/components/git/TaskPullRequestReviewFeed.tsx index 6a03ed0..2c386ff 100644 --- a/src/components/git/TaskPullRequestReviewFeed.tsx +++ b/src/components/git/TaskPullRequestReviewFeed.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from "react"; +import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { PrReviewFeedItemCommentThread, @@ -16,7 +16,12 @@ import { } from "~/db/queries/git"; import { useGitTaskLiveSync } from "~/hooks/use-git-task-live-sync"; import { formatClientError } from "~/lib/git/errors"; +import { + aggregateBotReviewDigests, + feedEntryNeedsAction, +} from "~/lib/git/bot-review-comment-summary"; import { buildPrReviewFeedEntries } from "~/lib/git/pr-review-feed-entries"; +import { Button } from "~/components/ui/button"; import { getLatestPr, getOpenPr } from "~/lib/git/task-dev-phase"; import { cn } from "~/lib/utils"; @@ -51,6 +56,7 @@ export function TaskPullRequestReviewFeed({ const [resolvingThreadNodeId, setResolvingThreadNodeId] = useState< string | null >(null); + const [needsActionOnly, setNeedsActionOnly] = useState(false); const canResolve = Boolean(connectionData?.userLink) && Boolean(reviewPr); @@ -81,22 +87,44 @@ export function TaskPullRequestReviewFeed({ setResolvingThreadNodeId(null); } }; - const entries = reviewData - ? buildPrReviewFeedEntries( - reviewData.reviews, - reviewData.comments, - reviewData.pendingComments, - reviewData.issueComments - ) - : []; + const entries = useMemo( + () => + reviewData + ? buildPrReviewFeedEntries( + reviewData.reviews, + reviewData.comments, + reviewData.pendingComments, + reviewData.issueComments + ) + : [], + [reviewData] + ); + + const botDigestRollup = useMemo( + () => + reviewData + ? aggregateBotReviewDigests(reviewData.issueComments) + : null, + [reviewData] + ); + + const visibleEntries = useMemo( + () => + needsActionOnly + ? entries.filter((entry) => feedEntryNeedsAction(entry)) + : entries, + [entries, needsActionOnly] + ); + + const hiddenByFilterCount = entries.length - visibleEntries.length; const scrollRef = useRef(null); useLayoutEffect(() => { const el = scrollRef.current; - if (!el || isLoading || entries.length === 0) return; + if (!el || isLoading || visibleEntries.length === 0) return; el.scrollTop = el.scrollHeight; - }, [entries, isLoading]); + }, [visibleEntries, isLoading]); return (
-
+
Pull request - {isFetching && !isLoading ? ( - - ) : null} +
+ {botDigestRollup && + (botDigestRollup.digestCount > 0 || needsActionOnly) ? ( + + ) : null} + {isFetching && !isLoading ? ( + + ) : null} +
@@ -130,6 +172,14 @@ export function TaskPullRequestReviewFeed({ ? "No reviews or comments yet." : "No reviews or comments yet. Connect your GitHub account in settings to see pending reviews and post comments."}

+ ) : visibleEntries.length === 0 ? ( +

+ Nothing needs action right now + {hiddenByFilterCount > 0 + ? ` (${hiddenByFilterCount} addressed item${hiddenByFilterCount === 1 ? "" : "s"} hidden)` + : ""} + . +

) : ( <> {!connectionData.userLink ? ( @@ -138,8 +188,28 @@ export function TaskPullRequestReviewFeed({ comments.

) : null} + {botDigestRollup && botDigestRollup.digestCount > 0 ? ( +
0 + ? "border-amber-400 bg-amber-100 text-amber-950" + : "border-emerald-500 bg-emerald-100 text-emerald-950" + )} + > + {botDigestRollup.openTotal > 0 + ? `${botDigestRollup.openTotal} CodeRabbit finding${botDigestRollup.openTotal === 1 ? "" : "s"} still need action across ${botDigestRollup.digestCount} review comment${botDigestRollup.digestCount === 1 ? "" : "s"}` + : `All CodeRabbit findings addressed (${botDigestRollup.digestCount} review comment${botDigestRollup.digestCount === 1 ? "" : "s"})`} +
+ ) : null} + {needsActionOnly && hiddenByFilterCount > 0 ? ( +

+ Hiding {hiddenByFilterCount} addressed or resolved item + {hiddenByFilterCount === 1 ? "" : "s"}. +

+ ) : null}
    - {entries.map((entry) => { + {visibleEntries.map((entry) => { if (entry.kind === "issue_comment") { return (
  • diff --git a/src/components/git/github-markdown-sanitize.ts b/src/components/git/github-markdown-sanitize.ts index a85d280..acc428e 100644 --- a/src/components/git/github-markdown-sanitize.ts +++ b/src/components/git/github-markdown-sanitize.ts @@ -9,7 +9,7 @@ export const githubMarkdownSanitizeSchema = { ], attributes: { ...defaultSchema.attributes, - details: [...(defaultSchema.attributes?.details ?? []), "open"], - summary: defaultSchema.attributes?.summary ?? [], + details: [...(defaultSchema.attributes?.details ?? []), "open", "class"], + summary: [...(defaultSchema.attributes?.summary ?? []), "class"], }, } as const; diff --git a/src/lib/git/bot-review-comment-summary.ts b/src/lib/git/bot-review-comment-summary.ts new file mode 100644 index 0000000..ee67be1 --- /dev/null +++ b/src/lib/git/bot-review-comment-summary.ts @@ -0,0 +1,139 @@ +import type { PrReviewFeedEntry } from "~/lib/git/pr-review-feed-entries"; +import type { GitPullRequestIssueComment } from "~/lib/git/types"; + +/** Parsed status from CodeRabbit-style PR issue comments (timeline). */ +export type BotReviewCommentSummary = { + actionableCount: number | null; + addressedCount: number; + openCount: number; + needsAction: boolean; + label: string; +}; + +const ACTIONABLE_COUNT_RE = /Actionable comments posted:\s*(\d+)/i; + +/** Per-file finding header in CodeRabbit digests, e.g. `\n`@src/foo.ts:12` */ +const FINDING_PATH_HEADER_RE = /(?:^|\n)`@[^`\n]+`/; + +/** CodeRabbit status line on a resolved finding, e.g. `✅ Addressed in commits abc to def`. */ +export const ADDRESSED_FINDING_STATUS_RE = + /(?:^|\n)\s*✅?\s*Addressed in commits?\s+[\da-f]+(?:\s+to\s+[\da-f]+)?/i; + +function countAddressedMarkers(body: string): number { + const matches = body.match( + new RegExp(ADDRESSED_FINDING_STATUS_RE.source, "gi") + ); + return matches?.length ?? 0; +} + +function isCoderabbitAuthor(login: string): boolean { + return /coderabbit/i.test(login); +} + +/** True when the body looks like a multi-finding bot review dump (CodeRabbit, etc.). */ +export function isBotReviewDigest(body: string): boolean { + return ( + ACTIONABLE_COUNT_RE.test(body) || + /Potential issue|Committable suggestion|Prompt for AI agents/i.test(body) || + (FINDING_PATH_HEADER_RE.test(body) && countAddressedMarkers(body) > 0) + ); +} + +export function summarizeBotReviewComment( + body: string, + options?: { authorLogin?: string } +): BotReviewCommentSummary | null { + const forceBot = + options?.authorLogin != null && isCoderabbitAuthor(options.authorLogin); + + if (!isBotReviewDigest(body) && !forceBot) return null; + + const actionableMatch = body.match(ACTIONABLE_COUNT_RE); + const actionableCount = actionableMatch + ? Number.parseInt(actionableMatch[1]!, 10) + : null; + + const addressedCount = countAddressedMarkers(body); + const baseSuggestedCount = + /Committable suggestion|Potential issue/i.test(body) ? 1 : 0; + const openCount = + actionableCount != null + ? Math.max(0, actionableCount - addressedCount) + : Math.max(0, baseSuggestedCount - addressedCount); + + const needsAction = openCount > 0; + + let label: string; + if (actionableCount != null) { + if (needsAction) { + label = `${openCount} finding${openCount === 1 ? "" : "s"} still need action`; + } else if (actionableCount > 0) { + label = `All ${actionableCount} finding${actionableCount === 1 ? "" : "s"} addressed`; + } else { + label = "No open findings"; + } + } else if (addressedCount > 0 && !needsAction) { + label = `${addressedCount} finding${addressedCount === 1 ? "" : "s"} addressed`; + } else if (needsAction) { + label = "Review feedback — action may be required"; + } else { + label = "Review feedback"; + } + + return { + actionableCount, + addressedCount, + openCount, + needsAction, + label, + }; +} + +export function summarizeBotReviewIssueComment( + comment: GitPullRequestIssueComment +): BotReviewCommentSummary | null { + const body = comment.body?.trim() ?? ""; + if (!body) return null; + if (!comment.isBot && !isCoderabbitAuthor(comment.authorLogin)) return null; + return summarizeBotReviewComment(body, { + authorLogin: comment.authorLogin, + }); +} + +export function aggregateBotReviewDigests( + issueComments: GitPullRequestIssueComment[] +): { openTotal: number; digestCount: number; allAddressed: boolean } { + let openTotal = 0; + let digestCount = 0; + + for (const comment of issueComments) { + const summary = summarizeBotReviewIssueComment(comment); + if (!summary) continue; + digestCount += 1; + openTotal += summary.openCount; + } + + return { + openTotal, + digestCount, + allAddressed: digestCount > 0 && openTotal === 0, + }; +} + +/** Whether a feed row should appear when "Needs action only" is enabled. */ +export function feedEntryNeedsAction(entry: PrReviewFeedEntry): boolean { + switch (entry.kind) { + case "issue_comment": { + const summary = summarizeBotReviewIssueComment(entry.comment); + if (summary) return summary.needsAction; + return true; + } + case "review": + return entry.review.state === "CHANGES_REQUESTED"; + case "thread": { + const { thread } = entry; + if (thread.pendingCommentIds.size > 0) return true; + return !thread.isResolved; + } + } +} diff --git a/src/lib/git/preprocess-github-markdown.ts b/src/lib/git/preprocess-github-markdown.ts index 47f38da..9e9039d 100644 --- a/src/lib/git/preprocess-github-markdown.ts +++ b/src/lib/git/preprocess-github-markdown.ts @@ -1,17 +1,69 @@ +import { + ADDRESSED_FINDING_STATUS_RE, + isBotReviewDigest, +} from "~/lib/git/bot-review-comment-summary"; + /** GitHub bots often nest `
    `; outer height clipping blocks interacting with them. */ export function hasGitHubCollapsibleSections(body: string): boolean { return /]/i.test(body); } -/** Prepare GitHub PR/issue comment bodies for markdown + limited HTML rendering. */ -export function preprocessGitHubMarkdown(body: string): string { +/** Per-file finding header in CodeRabbit digests, e.g. `\n`@src/foo.ts:12` */ +const FINDING_PATH_HEADER_RE = /(?:^|\n)`@[^`\n]+`/; + +const FINDING_BLOCK_SPLIT_RE = /(?=\n`@[^`\n]+`)/; + +function isAddressedFindingBlock(segment: string): boolean { return ( - body - // Bot metadata (e.g. coderabbit suggestion markers) - .replace(//g, "") - // GitHub-only fenced blocks → plain code fences for display - .replace(/```suggestion\n/g, "```\n") - .replace(/```suggestion\r\n/g, "```\r\n") - .trim() + FINDING_PATH_HEADER_RE.test(segment) && + ADDRESSED_FINDING_STATUS_RE.test(segment) ); } + +function addressedSummaryLine(segment: string): string { + const match = segment.match(ADDRESSED_FINDING_STATUS_RE); + return match?.[0]?.trim() ?? "✅ Addressed — show details"; +} + +function wrapAddressedSegment(segment: string): string { + const summary = addressedSummaryLine(segment); + return `
    \n${summary}\n\n${segment.trim()}\n
    `; +} + +/** Wrap CodeRabbit finding blocks that are already marked addressed (collapsed by default). */ +export function collapseAddressedBotFindings(body: string): string { + if (!isBotReviewDigest(body)) return body; + + const blocks = body.split(FINDING_BLOCK_SPLIT_RE); + if (blocks.length <= 1) { + return body; + } + + return blocks + .map((block, index) => { + if (index === 0) return block; + return isAddressedFindingBlock(block) + ? wrapAddressedSegment(block) + : block; + }) + .join(""); +} + +/** Prepare GitHub PR/issue comment bodies for markdown + limited HTML rendering. */ +export function preprocessGitHubMarkdown( + body: string, + options?: { collapseAddressedFindings?: boolean } +): string { + let result = body + // Bot metadata (e.g. coderabbit suggestion markers) + .replace(//g, "") + // GitHub-only fenced blocks → plain code fences for display + .replace(/```suggestion\n/g, "```\n") + .replace(/```suggestion\r\n/g, "```\r\n"); + + if (options?.collapseAddressedFindings) { + result = collapseAddressedBotFindings(result); + } + + return result.trim(); +}