Pull request
- {isFetching && !isLoading ? (
-
- ) : null}
+
+ {botDigestRollup &&
+ (botDigestRollup.digestCount > 0 || needsActionOnly) ? (
+ setNeedsActionOnly((v) => !v)}
+ >
+ Needs action only
+
+ ) : 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();
+}