From fe6eecba0cdecd1599df3c4c547b0a5a47e7a65b Mon Sep 17 00:00:00 2001 From: Yasin Kavakli Date: Sun, 26 Apr 2026 22:28:09 +0200 Subject: [PATCH] feat(sidebar): improve PR checks and comments (#1134) * feat(sidebar): improve PR checks and comments Co-authored-by: Orca * fix(sidebar): classify bot comments via GitHub user.type instead of login heuristic Third-party review bots like qodo-ai-reviewer, coderabbitai, and sonarcloud don't follow the [bot] suffix or "bot"/"automation" substring convention, so the regex-based detector misclassified them as human. Plumb REST `user.type === 'Bot'` and GraphQL `author.__typename === 'Bot'` through to the renderer as an authoritative isBot flag; fall back to the login heuristic only when the data source can't report it. Co-authored-by: Orca * fix(sidebar): allowlist AI review services that sign in as User accounts qodo-ai-reviewer, coderabbitai, codium-ai and similar third-party review services register as regular GitHub user accounts, so REST `user.type` is "User" and their logins contain no "bot"/"automation" tokens. The previous fix relied on the GitHub-reported type, which fails for these. Add an explicit substring allowlist of known automation services so they still land in the Bots tab. Co-authored-by: Orca --------- Co-authored-by: Orca Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/main/github/client.ts | 15 +- src/main/github/work-item-details.ts | 5 +- .../check-details-resize.test.ts | 10 + .../right-sidebar/check-details-resize.ts | 51 +++++ .../right-sidebar/checks-helpers.tsx | 174 +++++++++++++----- .../right-sidebar/pr-comment-filters.test.ts | 61 ++++++ .../right-sidebar/pr-comment-filters.ts | 69 +++++++ .../components/sidebar/CommentMarkdown.tsx | 11 +- src/shared/types.ts | 6 + 9 files changed, 348 insertions(+), 54 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/check-details-resize.test.ts create mode 100644 src/renderer/src/components/right-sidebar/check-details-resize.ts create mode 100644 src/renderer/src/components/right-sidebar/pr-comment-filters.test.ts create mode 100644 src/renderer/src/components/right-sidebar/pr-comment-filters.ts diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 2a3216ee1c3..d1586f506a9 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -860,7 +860,7 @@ query($owner: String!, $repo: String!, $pr: Int!) { comments(first: 100) { nodes { databaseId - author { login avatarUrl(size: 48) } + author { __typename login avatarUrl(size: 48) } body createdAt url @@ -932,7 +932,7 @@ export async function getPRComments( // Parse issue comments (REST) type RESTComment = { id: number - user: { login: string; avatar_url: string } | null + user: { login: string; avatar_url: string; type?: string } | null body: string created_at: string html_url: string @@ -946,7 +946,8 @@ export async function getPRComments( authorAvatarUrl: c.user?.avatar_url ?? '', body: c.body ?? '', createdAt: c.created_at, - url: c.html_url + url: c.html_url, + isBot: c.user?.type === 'Bot' }) ) } else { @@ -964,7 +965,7 @@ export async function getPRComments( comments: { nodes: { databaseId: number - author: { login: string; avatarUrl: string } | null + author: { __typename?: string; login: string; avatarUrl: string } | null body: string createdAt: string url: string @@ -990,6 +991,7 @@ export async function getPRComments( path: c.path, threadId: thread.id, isResolved: thread.isResolved, + isBot: c.author?.__typename === 'Bot', // Why: GitHub nulls out line/startLine when the commented code is // outdated (e.g. after a force-push). Fall back to originalLine which // always preserves the line numbers from when the comment was created. @@ -1006,7 +1008,7 @@ export async function getPRComments( // since empty-body reviews (e.g. approvals with no comment) add noise. type RESTReview = { id: number - user: { login: string; avatar_url: string } | null + user: { login: string; avatar_url: string; type?: string } | null body: string state: string submitted_at: string @@ -1023,7 +1025,8 @@ export async function getPRComments( authorAvatarUrl: r.user?.avatar_url ?? '', body: r.body, createdAt: r.submitted_at, - url: r.html_url + url: r.html_url, + isBot: r.user?.type === 'Bot' }) ) } else { diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 9aa24e2e1d3..c1f3b9e82a1 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -158,7 +158,7 @@ async function getIssueBodyAndComments( } type RESTComment = { id: number - user: { login: string; avatar_url: string } | null + user: { login: string; avatar_url: string; type?: string } | null body: string created_at: string html_url: string @@ -170,7 +170,8 @@ async function getIssueBodyAndComments( authorAvatarUrl: c.user?.avatar_url ?? '', body: c.body ?? '', createdAt: c.created_at, - url: c.html_url + url: c.html_url, + isBot: c.user?.type === 'Bot' }) ) const assignees = (issue.assignees ?? []).map((a) => a.login) diff --git a/src/renderer/src/components/right-sidebar/check-details-resize.test.ts b/src/renderer/src/components/right-sidebar/check-details-resize.test.ts new file mode 100644 index 00000000000..617888d674c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/check-details-resize.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { clampCheckDetailsHeight } from './check-details-resize' + +describe('clampCheckDetailsHeight', () => { + it('keeps the checks list resize height within readable bounds', () => { + expect(clampCheckDetailsHeight(20)).toBe(72) + expect(clampCheckDetailsHeight(260)).toBe(260) + expect(clampCheckDetailsHeight(900)).toBe(520) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/check-details-resize.ts b/src/renderer/src/components/right-sidebar/check-details-resize.ts new file mode 100644 index 00000000000..e5bf53952dc --- /dev/null +++ b/src/renderer/src/components/right-sidebar/check-details-resize.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' + +const DEFAULT_CHECK_DETAILS_HEIGHT = 260 +const MIN_CHECK_DETAILS_HEIGHT = 72 +const MAX_CHECK_DETAILS_HEIGHT = 520 + +export function clampCheckDetailsHeight(height: number): number { + return Math.min(MAX_CHECK_DETAILS_HEIGHT, Math.max(MIN_CHECK_DETAILS_HEIGHT, height)) +} + +export function useCheckDetailsResize(enabled: boolean): { + detailsHeight: number + handleResizeStart: (event: ReactMouseEvent) => void +} { + const [detailsHeight, setDetailsHeight] = useState(DEFAULT_CHECK_DETAILS_HEIGHT) + const dragStartRef = useRef<{ y: number; height: number } | null>(null) + + const handleResizeStart = useCallback( + (event: ReactMouseEvent) => { + if (!enabled) { + return + } + event.preventDefault() + dragStartRef.current = { y: event.clientY, height: detailsHeight } + }, + [detailsHeight, enabled] + ) + + useEffect(() => { + const handleMouseMove = (event: MouseEvent): void => { + const dragStart = dragStartRef.current + if (!dragStart) { + return + } + setDetailsHeight(clampCheckDetailsHeight(dragStart.height + event.clientY - dragStart.y)) + } + + const handleMouseUp = (): void => { + dragStartRef.current = null + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + }, []) + + return { detailsHeight, handleResizeStart } +} diff --git a/src/renderer/src/components/right-sidebar/checks-helpers.tsx b/src/renderer/src/components/right-sidebar/checks-helpers.tsx index bab601c88bf..e29a7d1236a 100644 --- a/src/renderer/src/components/right-sidebar/checks-helpers.tsx +++ b/src/renderer/src/components/right-sidebar/checks-helpers.tsx @@ -11,11 +11,19 @@ import { Files, Copy, Check, - MessageSquare + MessageSquare, + ChevronDown } from 'lucide-react' import { ExternalLink } from 'lucide-react' import { cn } from '@/lib/utils' +import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import type { PRInfo, PRCheckDetail, PRComment } from '../../../../shared/types' +import { + filterPRCommentsByAudience, + isAutomatedPRComment, + type PRCommentAudienceFilter +} from './pr-comment-filters' +import { useCheckDetailsResize } from './check-details-resize' export const PullRequestIcon = GitPullRequest @@ -106,6 +114,10 @@ export function ChecksList({ checks: PRCheckDetail[] checksLoading: boolean }): React.JSX.Element { + const [checksExpanded, setChecksExpanded] = useState(true) + const { detailsHeight, handleResizeStart } = useCheckDetailsResize( + checksExpanded && checks.length > 0 + ) const sorted = [...checks].sort( (a, b) => (CHECK_SORT_ORDER[a.conclusion ?? 'pending'] ?? 3) - @@ -123,7 +135,15 @@ export function ChecksList({ <> {/* Checks Summary */} {checks.length > 0 && ( -
+
+ + {checksLoading && } + )} {/* Checks List */} @@ -154,38 +176,54 @@ export function ChecksList({
No checks configured
- ) : ( -
- {sorted.map((check) => { - const conclusion = check.conclusion ?? 'pending' - const Icon = CHECK_ICON[conclusion] ?? CircleDashed - const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground' - return ( -
{ - if (check.url) { - window.api.shell.openUrl(check.url) - } - }} - > - +
+ {sorted.map((check) => { + const conclusion = check.conclusion ?? 'pending' + const Icon = CHECK_ICON[conclusion] ?? CircleDashed + const color = CHECK_COLOR[conclusion] ?? 'text-muted-foreground' + return ( +
- {check.name} - {check.url && } -
- ) - })} -
+ onClick={() => { + if (check.url) { + window.api.shell.openUrl(check.url) + } + }} + > + + {check.name} + {check.url && ( + + )} +
+ ) + })} +
+
+
+
+ )} ) @@ -284,6 +322,7 @@ function CommentRow({ showResolve: boolean onResolve?: (threadId: string, resolve: boolean) => void }): React.JSX.Element { + const automated = isAutomatedPRComment(comment) return (
{comment.author} + {automated && ( + + bot + + )} {!isReply && comment.path && ( {comment.path.split('/').pop()} @@ -337,15 +381,14 @@ function CommentRow({
- {/* Comment body */} -

- {comment.body} -

+ /> ) @@ -404,16 +447,53 @@ export function PRCommentsList({ commentsLoading: boolean onResolve?: (threadId: string, resolve: boolean) => void }): React.JSX.Element { - const groups = React.useMemo(() => groupComments(comments), [comments]) + const [audienceFilter, setAudienceFilter] = useState('all') + const humanCount = React.useMemo( + () => comments.filter((comment) => !isAutomatedPRComment(comment)).length, + [comments] + ) + const botCount = comments.length - humanCount + const filteredComments = React.useMemo( + () => filterPRCommentsByAudience(comments, audienceFilter), + [comments, audienceFilter] + ) + const groups = React.useMemo(() => groupComments(filteredComments), [filteredComments]) + const filterOptions: { value: PRCommentAudienceFilter; label: string; count: number }[] = [ + { value: 'all', label: 'All', count: comments.length }, + { value: 'human', label: 'Humans', count: humanCount }, + { value: 'bot', label: 'Bots', count: botCount } + ] return (
{/* Header */} -
- - Comments +
+
+ + Comments + {comments.length > 0 && ( + {comments.length} + )} +
{comments.length > 0 && ( - {comments.length} +
+ {filterOptions.map((option) => ( + + ))} +
)}
@@ -426,6 +506,10 @@ export function PRCommentsList({
No comments
+ ) : filteredComments.length === 0 ? ( +
+ No {audienceFilter === 'human' ? 'human' : 'bot'} comments +
) : (
{groups.map((group) => { diff --git a/src/renderer/src/components/right-sidebar/pr-comment-filters.test.ts b/src/renderer/src/components/right-sidebar/pr-comment-filters.test.ts new file mode 100644 index 00000000000..9bcb57cf3f0 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/pr-comment-filters.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import type { PRComment } from '../../../../shared/types' +import { filterPRCommentsByAudience, isAutomatedPRComment } from './pr-comment-filters' + +function comment(author: string): PRComment { + return { + id: author.length, + author, + authorAvatarUrl: '', + body: 'body', + createdAt: '2026-04-26T00:00:00.000Z', + url: '' + } +} + +describe('pr-comment-filters', () => { + it('classifies GitHub app and automation authors as bot comments', () => { + expect(isAutomatedPRComment(comment('github-actions[bot]'))).toBe(true) + expect(isAutomatedPRComment(comment('dependabot[bot]'))).toBe(true) + expect(isAutomatedPRComment(comment('renovate-bot'))).toBe(true) + expect(isAutomatedPRComment(comment('preview-automation'))).toBe(true) + }) + + it('keeps normal user logins as human comments', () => { + expect(isAutomatedPRComment(comment('octocat'))).toBe(false) + expect(isAutomatedPRComment(comment('robotics-dev'))).toBe(false) + }) + + it('trusts the GitHub-provided isBot flag when true', () => { + expect(isAutomatedPRComment({ ...comment('github-actions[bot]'), isBot: true })).toBe(true) + }) + + it('classifies known AI review services that register as User accounts', () => { + // These sign in as regular GitHub users, so GitHub's user.type is 'User' + // and their logins have no "bot" / "automation" tokens. Allowlist picks + // them up regardless. + expect(isAutomatedPRComment({ ...comment('qodo-ai-reviewer'), isBot: false })).toBe(true) + expect(isAutomatedPRComment({ ...comment('coderabbitai'), isBot: false })).toBe(true) + expect(isAutomatedPRComment(comment('sonarcloud'))).toBe(true) + expect(isAutomatedPRComment(comment('codium-ai-reviewer'))).toBe(true) + }) + + it('filters comments by audience', () => { + const comments = [ + comment('octocat'), + comment('github-actions[bot]'), + comment('mona'), + comment('dependabot[bot]') + ] + + expect(filterPRCommentsByAudience(comments, 'all')).toEqual(comments) + expect(filterPRCommentsByAudience(comments, 'human').map((c) => c.author)).toEqual([ + 'octocat', + 'mona' + ]) + expect(filterPRCommentsByAudience(comments, 'bot').map((c) => c.author)).toEqual([ + 'github-actions[bot]', + 'dependabot[bot]' + ]) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/pr-comment-filters.ts b/src/renderer/src/components/right-sidebar/pr-comment-filters.ts new file mode 100644 index 00000000000..14881b05222 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/pr-comment-filters.ts @@ -0,0 +1,69 @@ +import type { PRComment } from '../../../../shared/types' + +export type PRCommentAudienceFilter = 'all' | 'human' | 'bot' + +const BOT_LOGIN_SUFFIX = '[bot]' +const AUTOMATION_LOGIN_PATTERNS = [ + /bot$/i, + /-bot$/i, + /\bbot\b/i, + /automation/i, + /actions/i, + /renovate/i, + /dependabot/i +] +// Why: several AI code-review services register as regular GitHub *user* +// accounts rather than GitHub Apps, so REST `user.type` returns "User" and +// their logins don't contain "bot"/"automation" either (qodo-ai-reviewer, +// coderabbitai, codium-ai, etc.). Maintain an explicit allowlist so these +// still land in the Bots tab. Matched as a substring to cover variants +// like `coderabbitai[bot]`, `qodo-ai-reviewer`, `qodo-merge-pro`. +const KNOWN_AUTOMATION_LOGIN_SUBSTRINGS = [ + 'qodo', + 'coderabbit', + 'codium', + 'sonarcloud', + 'sonarqube', + 'sourcery-ai', + 'deepsource', + 'snyk', + 'codecov', + 'greptile', + 'ellipsis', + 'graphite-app', + 'reviewer-gpt', + '-reviewer' +] + +export function isAutomatedPRComment(comment: PRComment): boolean { + // Why: GitHub's REST `user.type === 'Bot'` and GraphQL `author.__typename === 'Bot'` + // only flag accounts registered as GitHub Apps. Several popular AI reviewers + // (qodo-ai-reviewer, coderabbitai) sign in as regular *user* accounts, so + // `isBot` returns false for them. Treat isBot=true as authoritative, but when + // absent or false, fall through to login heuristics + a known-bot allowlist. + if (comment.isBot === true) { + return true + } + const author = comment.author.trim() + const normalized = author.toLowerCase() + if (normalized.endsWith(BOT_LOGIN_SUFFIX)) { + return true + } + if (KNOWN_AUTOMATION_LOGIN_SUBSTRINGS.some((needle) => normalized.includes(needle))) { + return true + } + return AUTOMATION_LOGIN_PATTERNS.some((pattern) => pattern.test(author)) +} + +export function filterPRCommentsByAudience( + comments: PRComment[], + filter: PRCommentAudienceFilter +): PRComment[] { + if (filter === 'all') { + return comments + } + return comments.filter((comment) => { + const automated = isAutomatedPRComment(comment) + return filter === 'bot' ? automated : !automated + }) +} diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx index 4c90a6c8db2..4ce094ba71a 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx @@ -2,6 +2,8 @@ import React from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize from 'rehype-sanitize' import type { Components } from 'react-markdown' import { cn } from '@/lib/utils' @@ -99,6 +101,9 @@ const components: Components = { // remark-breaks converts single newlines to
, keeping backward compat // with existing plain-text comments that rely on newline formatting. const remarkPlugins = [remarkGfm, remarkBreaks] +// Why: GitHub comments commonly contain trusted-looking raw HTML from bots +// (``, `

`). Parse it for readability, then sanitize before render. +const rehypePlugins = [rehypeRaw, rehypeSanitize] type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & { content: string @@ -124,7 +129,11 @@ const CommentMarkdown = React.memo( )} {...rest} > - + {content} diff --git a/src/shared/types.ts b/src/shared/types.ts index 19756dd9821..8965d232c97 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -381,6 +381,12 @@ export type PRComment = { line?: number /** Start line of the review annotation range (1-based). Absent for single-line comments. */ startLine?: number + /** True when GitHub identifies the author as a bot (REST `user.type === 'Bot'` or + * GraphQL `__typename === 'Bot'`). Preferred over login-string heuristics because + * third-party review bots (e.g. qodo-ai-reviewer, coderabbitai) don't follow a + * predictable naming convention. Absent when the data source can't report it + * (non-GitHub fallbacks via `gh pr view`). */ + isBot?: boolean } export type IssueInfo = {