feat: add PR comments to checks panel (#376)

This commit is contained in:
Jinwoo Hong 2026-04-07 20:58:40 -04:00 committed by GitHub
parent 755690b5a2
commit beb62ff580
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 785 additions and 10 deletions

View File

@ -1,4 +1,6 @@
import type { PRInfo, PRMergeableState, PRCheckDetail } from '../../shared/types'
/* eslint-disable max-lines -- Why: co-locating all GitHub client functions keeps the
concurrency acquire/release pattern and error handling consistent across operations. */
import type { PRInfo, PRMergeableState, PRCheckDetail, PRComment } from '../../shared/types'
import { getPRConflictSummary } from './conflict-summary'
import { execFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
export { _resetOwnerRepoCache } from './gh-utils'
@ -225,6 +227,255 @@ export async function getPRChecks(
}
}
// Why: review thread resolution status and thread IDs are only available via
// GraphQL. The REST pulls/{n}/comments endpoint does not expose them, so we
// use GraphQL for review threads and REST for issue-level comments.
const REVIEW_THREADS_QUERY = `
query($owner: String!, $repo: String!, $pr: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
reviewThreads(first: 100) {
nodes {
id
isResolved
line
startLine
originalLine
originalStartLine
comments(first: 100) {
nodes {
databaseId
author { login avatarUrl(size: 48) }
body
createdAt
url
path
}
}
}
}
}
}
}`
/**
* Get all comments on a PR both top-level conversation comments and inline
* review comments (including suggestions). Uses GraphQL for review threads
* to get resolution status, REST for issue-level comments.
*/
export async function getPRComments(
repoPath: string,
prNumber: number,
options?: { noCache?: boolean }
): Promise<PRComment[]> {
const ownerRepo = await getOwnerRepo(repoPath)
await acquire()
try {
if (ownerRepo) {
// Why: --cache 60s saves rate-limit budget during normal loads, but when the
// user explicitly clicks refresh we must skip it so gh fetches fresh data.
const cacheArgs = options?.noCache ? [] : ['--cache', '60s']
const base = `repos/${ownerRepo.owner}/${ownerRepo.repo}`
// Why: use allSettled so a single failing endpoint (e.g. GraphQL
// permissions, transient network error) doesn't blank out all comments.
// Each source is parsed independently; failed sources contribute zero
// comments instead of aborting the entire fetch.
const [issueResult, threadsResult, reviewsResult] = await Promise.allSettled([
execFileAsync(
'gh',
['api', ...cacheArgs, `${base}/issues/${prNumber}/comments?per_page=100`],
{ cwd: repoPath, encoding: 'utf-8' }
),
execFileAsync(
'gh',
[
'api',
'graphql',
'-f',
`query=${REVIEW_THREADS_QUERY}`,
'-f',
`owner=${ownerRepo.owner}`,
'-f',
`repo=${ownerRepo.repo}`,
'-F',
`pr=${prNumber}`
],
{ cwd: repoPath, encoding: 'utf-8' }
),
// Why: review summaries (approve, request changes, general comments) live
// under pulls/{n}/reviews, not under issue comments or review threads.
// Without this, a reviewer who submits "LGTM" without inline threads
// would have their comment silently dropped from the panel.
execFileAsync(
'gh',
['api', ...cacheArgs, `${base}/pulls/${prNumber}/reviews?per_page=100`],
{ cwd: repoPath, encoding: 'utf-8' }
)
])
// Parse issue comments (REST)
type RESTComment = {
id: number
user: { login: string; avatar_url: string } | null
body: string
created_at: string
html_url: string
}
let issueComments: PRComment[] = []
if (issueResult.status === 'fulfilled') {
issueComments = (JSON.parse(issueResult.value.stdout) as RESTComment[]).map(
(c): PRComment => ({
id: c.id,
author: c.user?.login ?? 'ghost',
authorAvatarUrl: c.user?.avatar_url ?? '',
body: c.body ?? '',
createdAt: c.created_at,
url: c.html_url
})
)
} else {
console.warn('Failed to fetch issue comments:', issueResult.reason)
}
// Parse review threads (GraphQL)
type GQLThread = {
id: string
isResolved: boolean
line: number | null
startLine: number | null
originalLine: number | null
originalStartLine: number | null
comments: {
nodes: {
databaseId: number
author: { login: string; avatarUrl: string } | null
body: string
createdAt: string
url: string
path: string
}[]
}
}
const reviewComments: PRComment[] = []
if (threadsResult.status === 'fulfilled') {
const threadsData = JSON.parse(threadsResult.value.stdout) as {
data: { repository: { pullRequest: { reviewThreads: { nodes: GQLThread[] } } } }
}
const threads = threadsData.data.repository.pullRequest.reviewThreads.nodes
for (const thread of threads) {
for (const c of thread.comments.nodes) {
reviewComments.push({
id: c.databaseId,
author: c.author?.login ?? 'ghost',
authorAvatarUrl: c.author?.avatarUrl ?? '',
body: c.body ?? '',
createdAt: c.createdAt,
url: c.url,
path: c.path,
threadId: thread.id,
isResolved: thread.isResolved,
// 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.
line: thread.line ?? thread.originalLine ?? undefined,
startLine: thread.startLine ?? thread.originalStartLine ?? undefined
})
}
}
} else {
console.warn('Failed to fetch review threads:', threadsResult.reason)
}
// Parse review summaries (REST) — only include reviews with a body,
// since empty-body reviews (e.g. approvals with no comment) add noise.
type RESTReview = {
id: number
user: { login: string; avatar_url: string } | null
body: string
state: string
submitted_at: string
html_url: string
}
let reviewSummaries: PRComment[] = []
if (reviewsResult.status === 'fulfilled') {
reviewSummaries = (JSON.parse(reviewsResult.value.stdout) as RESTReview[])
.filter((r) => r.body?.trim())
.map(
(r): PRComment => ({
id: r.id,
author: r.user?.login ?? 'ghost',
authorAvatarUrl: r.user?.avatar_url ?? '',
body: r.body,
createdAt: r.submitted_at,
url: r.html_url
})
)
} else {
console.warn('Failed to fetch review summaries:', reviewsResult.reason)
}
const all = [...issueComments, ...reviewComments, ...reviewSummaries]
all.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
return all
}
// Fallback: non-GitHub remote — use gh pr view (only returns issue-level comments)
const { stdout } = await execFileAsync(
'gh',
['pr', 'view', String(prNumber), '--json', 'comments'],
{ cwd: repoPath, encoding: 'utf-8' }
)
const data = JSON.parse(stdout) as {
comments: {
author: { login: string }
body: string
createdAt: string
url: string
}[]
}
return (data.comments ?? []).map((c, i) => ({
id: i,
author: c.author?.login ?? 'ghost',
authorAvatarUrl: '',
body: c.body ?? '',
createdAt: c.createdAt,
url: c.url ?? ''
}))
} catch (err) {
console.warn('getPRComments failed:', err)
return []
} finally {
release()
}
}
/**
* Resolve or unresolve a PR review thread via GraphQL.
*/
export async function resolveReviewThread(
repoPath: string,
threadId: string,
resolve: boolean
): Promise<boolean> {
const mutation = resolve ? 'resolveReviewThread' : 'unresolveReviewThread'
const query = `mutation($threadId: ID!) { ${mutation}(input: { threadId: $threadId }) { thread { isResolved } } }`
await acquire()
try {
await execFileAsync(
'gh',
['api', 'graphql', '-f', `query=${query}`, '-f', `threadId=${threadId}`],
{ cwd: repoPath, encoding: 'utf-8' }
)
return true
} catch (err) {
console.warn(`${mutation} failed:`, err)
return false
} finally {
release()
}
}
/**
* Merge a PR by number using gh CLI.
* method: 'merge' | 'squash' | 'rebase' (default: 'squash')

View File

@ -6,6 +6,8 @@ import {
getIssue,
listIssues,
getPRChecks,
getPRComments,
resolveReviewThread,
updatePRTitle,
mergePR,
checkOrcaStarred,
@ -55,6 +57,22 @@ export function registerGitHubHandlers(store: Store): void {
}
)
ipcMain.handle(
'gh:prComments',
(_event, args: { repoPath: string; prNumber: number; noCache?: boolean }) => {
const repoPath = assertRegisteredRepoPath(args.repoPath, store)
return getPRComments(repoPath, args.prNumber, { noCache: args.noCache })
}
)
ipcMain.handle(
'gh:resolveReviewThread',
(_event, args: { repoPath: string; threadId: string; resolve: boolean }) => {
const repoPath = assertRegisteredRepoPath(args.repoPath, store)
return resolveReviewThread(repoPath, args.threadId, args.resolve)
}
)
ipcMain.handle(
'gh:updatePRTitle',
(_event, args: { repoPath: string; prNumber: number; title: string }) => {

View File

@ -8,6 +8,7 @@ import type {
CreateWorktreeResult,
PRInfo,
PRCheckDetail,
PRComment,
IssueInfo,
GlobalSettings,
NotificationDispatchRequest,
@ -82,6 +83,16 @@ type GhApi = {
headSha?: string
noCache?: boolean
}) => Promise<PRCheckDetail[]>
prComments: (args: {
repoPath: string
prNumber: number
noCache?: boolean
}) => Promise<PRComment[]>
resolveReviewThread: (args: {
repoPath: string
threadId: string
resolve: boolean
}) => Promise<boolean>
updatePRTitle: (args: { repoPath: string; prNumber: number; title: string }) => Promise<boolean>
mergePR: (args: {
repoPath: string

View File

@ -228,6 +228,18 @@ const api = {
noCache?: boolean
}): Promise<unknown[]> => ipcRenderer.invoke('gh:prChecks', args),
prComments: (args: {
repoPath: string
prNumber: number
noCache?: boolean
}): Promise<unknown[]> => ipcRenderer.invoke('gh:prComments', args),
resolveReviewThread: (args: {
repoPath: string
threadId: string
resolve: boolean
}): Promise<boolean> => ipcRenderer.invoke('gh:resolveReviewThread', args),
updatePRTitle: (args: {
repoPath: string
prNumber: number

View File

@ -1,3 +1,5 @@
/* eslint-disable max-lines -- Why: the checks panel co-locates PR header, checks, comments,
merge actions, and conflict state in one component to keep the data flow straightforward. */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { LoaderCircle, ExternalLink, RefreshCw, Check, X, Pencil } from 'lucide-react'
import { useAppStore } from '@/store'
@ -9,9 +11,10 @@ import {
prStateColor,
ConflictingFilesSection,
MergeConflictNotice,
ChecksList
ChecksList,
PRCommentsList
} from './checks-helpers'
import type { PRInfo, PRCheckDetail } from '../../../../shared/types'
import type { PRInfo, PRCheckDetail, PRComment } from '../../../../shared/types'
export default function ChecksPanel(): React.JSX.Element {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
@ -22,9 +25,13 @@ export default function ChecksPanel(): React.JSX.Element {
const gitConflictOperationByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree)
const fetchPRChecks = useAppStore((s) => s.fetchPRChecks)
const fetchPRComments = useAppStore((s) => s.fetchPRComments)
const resolveReviewThread = useAppStore((s) => s.resolveReviewThread)
const [checks, setChecks] = useState<PRCheckDetail[]>([])
const [checksLoading, setChecksLoading] = useState(false)
const [comments, setComments] = useState<PRComment[]>([])
const [commentsLoading, setCommentsLoading] = useState(false)
const [emptyRefreshing, setEmptyRefreshing] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [editingTitle, setEditingTitle] = useState(false)
@ -153,6 +160,60 @@ export default function ChecksPanel(): React.JSX.Element {
}
}, [fetchChecks, prNumber])
// Fetch comments once when PR changes (no polling — comments change infrequently).
// The manual refresh path calls this directly; the auto-fetch effect below uses
// its own cancellation guard to discard stale responses after PR switches.
const fetchComments = useCallback(
async ({
force = false,
prNumberOverride
}: { force?: boolean; prNumberOverride?: number | null } = {}) => {
const targetPRNumber = prNumberOverride ?? prNumber
if (!repo || !targetPRNumber) {
return
}
setCommentsLoading(true)
try {
const result = await fetchPRComments(repo.path, targetPRNumber, { force })
setComments(result)
} catch (err) {
console.warn('Failed to fetch PR comments:', err)
setComments([])
} finally {
setCommentsLoading(false)
}
},
[repo, prNumber, fetchPRComments]
)
useEffect(() => {
if (!repo || !prNumber) {
setComments([])
return
}
// Why: without this guard a slow response from a previous PR can overwrite
// state after the user switches worktrees, showing the wrong PR's comments.
let cancelled = false
setCommentsLoading(true)
void fetchPRComments(repo.path, prNumber).then(
(result) => {
if (!cancelled) {
setComments(result)
setCommentsLoading(false)
}
},
() => {
if (!cancelled) {
setComments([])
setCommentsLoading(false)
}
}
)
return () => {
cancelled = true
}
}, [repo, prNumber, fetchPRComments])
const handleRefresh = useCallback(async () => {
if (!repo || !branch) {
return
@ -161,14 +222,18 @@ export default function ChecksPanel(): React.JSX.Element {
try {
const refreshedPR = await fetchPRForBranch(repo.path, branch, { force: true })
if (refreshedPR) {
await fetchChecks({ force: true, prNumberOverride: refreshedPR.number })
await Promise.all([
fetchChecks({ force: true, prNumberOverride: refreshedPR.number }),
fetchComments({ force: true, prNumberOverride: refreshedPR.number })
])
} else {
setChecks([])
setComments([])
}
} finally {
setIsRefreshing(false)
}
}, [repo, branch, fetchPRForBranch, fetchChecks])
}, [repo, branch, fetchPRForBranch, fetchChecks, fetchComments])
const handleStartEdit = useCallback(() => {
if (!pr) {
@ -218,6 +283,23 @@ export default function ChecksPanel(): React.JSX.Element {
[handleSaveTitle, handleCancelEdit]
)
const handleResolve = useCallback(
(threadId: string, resolve: boolean) => {
if (!repo || !prNumber) {
return
}
void resolveReviewThread(repo.path, prNumber, threadId, resolve).then((ok) => {
if (ok) {
// Update local state to match the optimistic store update
setComments((prev) =>
prev.map((c) => (c.threadId === threadId ? { ...c, isResolved: resolve } : c))
)
}
})
},
[repo, prNumber, resolveReviewThread]
)
// Refresh PR (passed to PRActions)
const handleRefreshPR = useCallback(async () => {
if (repo && branch) {
@ -397,6 +479,11 @@ export default function ChecksPanel(): React.JSX.Element {
{!(pr.mergeable === 'CONFLICTING' && checks.length === 0 && !checksLoading) && (
<ChecksList checks={checks} checksLoading={checksLoading} />
)}
<PRCommentsList
comments={comments}
commentsLoading={commentsLoading}
onResolve={handleResolve}
/>
</div>
)
}

View File

@ -1,3 +1,6 @@
/* eslint-disable max-lines -- Why: co-locating all checks-panel sub-components (checks list,
conflict sections, threaded PR comments) keeps the shared icon/color maps in one place. */
import React, { useCallback, useState } from 'react'
import {
CircleCheck,
CircleX,
@ -5,11 +8,14 @@ import {
CircleDashed,
CircleMinus,
GitPullRequest,
Files
Files,
Copy,
Check,
MessageSquare
} from 'lucide-react'
import { ExternalLink } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { PRInfo, PRCheckDetail } from '../../../../shared/types'
import type { PRInfo, PRCheckDetail, PRComment } from '../../../../shared/types'
export const PullRequestIcon = GitPullRequest
@ -185,6 +191,285 @@ export function ChecksList({
)
}
function CopyButton({ text }: { text: string }): React.JSX.Element {
const [copied, setCopied] = useState(false)
const handleCopy = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
void window.api.ui.writeClipboardText(text).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
},
[text]
)
return (
<button
className="p-1 rounded hover:bg-accent text-muted-foreground/40 hover:text-foreground transition-colors shrink-0"
title="Copy comment"
onClick={handleCopy}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
</button>
)
}
function ResolveButton({
threadId,
isResolved,
onResolve
}: {
threadId: string
isResolved: boolean
onResolve: (threadId: string, resolve: boolean) => void
}): React.JSX.Element {
const [loading, setLoading] = useState(false)
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
setLoading(true)
onResolve(threadId, !isResolved)
setTimeout(() => setLoading(false), 300)
},
[threadId, isResolved, onResolve]
)
if (loading) {
return <LoaderCircle className="size-3 animate-spin text-muted-foreground shrink-0" />
}
return (
<button
className="text-[10px] px-1.5 py-0.5 rounded transition-colors shrink-0 text-muted-foreground hover:text-foreground hover:bg-accent"
onClick={handleClick}
>
{isResolved ? 'Unresolve' : 'Resolve'}
</button>
)
}
/** Format a line range string like "L12" or "L5-L12". */
function formatLineRange(comment: PRComment): string | null {
if (!comment.line) {
return null
}
if (comment.startLine && comment.startLine !== comment.line) {
return `L${comment.startLine}-L${comment.line}`
}
return `L${comment.line}`
}
/** Build copy text that includes file location context for review comments. */
function buildCopyText(comment: PRComment): string {
if (!comment.path) {
return comment.body
}
const lineRange = formatLineRange(comment)
const location = lineRange ? `${comment.path}:${lineRange}` : comment.path
return `File: ${location}\n\n${comment.body}`
}
/** A single comment row — used for both root and reply comments. */
function CommentRow({
comment,
isReply,
showResolve,
onResolve
}: {
comment: PRComment
isReply: boolean
showResolve: boolean
onResolve?: (threadId: string, resolve: boolean) => void
}): React.JSX.Element {
return (
<div
className={cn(
'flex items-start gap-2 py-1.5 hover:bg-accent/40 transition-colors cursor-pointer group/comment',
isReply ? 'pl-7 pr-3' : 'px-3',
comment.isResolved && 'opacity-50'
)}
onClick={() => {
if (comment.url) {
window.api.shell.openUrl(comment.url)
}
}}
>
<div className="flex-1 min-w-0">
{/* Author line: avatar + name + file badge aligned on center */}
<div className="flex items-center gap-1.5 min-w-0">
{comment.authorAvatarUrl ? (
<img
src={comment.authorAvatarUrl}
alt={comment.author}
className={cn('rounded-full shrink-0', isReply ? 'size-3.5' : 'size-4')}
/>
) : (
<div
className={cn('rounded-full bg-muted shrink-0', isReply ? 'size-3.5' : 'size-4')}
/>
)}
<span
className={cn(
'text-[11px] font-semibold shrink-0',
comment.isResolved ? 'text-muted-foreground' : 'text-foreground'
)}
>
{comment.author}
</span>
{!isReply && comment.path && (
<span className="text-[10px] font-mono text-muted-foreground/60 truncate min-w-0">
{comment.path.split('/').pop()}
{formatLineRange(comment) && `:${formatLineRange(comment)}`}
</span>
)}
<div className="flex-1" />
<div className="flex items-center gap-0.5 opacity-0 group-hover/comment:opacity-100 transition-opacity">
{showResolve && comment.threadId != null && onResolve && (
<ResolveButton
threadId={comment.threadId}
isResolved={comment.isResolved ?? false}
onResolve={onResolve}
/>
)}
<CopyButton text={buildCopyText(comment)} />
</div>
</div>
{/* Comment body */}
<p
className={cn(
'text-[11px] text-muted-foreground leading-snug mt-0.5',
isReply ? 'pl-5 line-clamp-1' : 'pl-[22px] line-clamp-2'
)}
>
{comment.body}
</p>
</div>
</div>
)
}
/** Group structure for organizing comments by thread. */
type CommentGroup =
| { kind: 'standalone'; comment: PRComment }
| { kind: 'thread'; threadId: string; root: PRComment; replies: PRComment[] }
/** Groups comments by threadId. Comments without a threadId are standalone. */
function groupComments(comments: PRComment[]): CommentGroup[] {
const groups: CommentGroup[] = []
const threadMap = new Map<string, { root: PRComment; replies: PRComment[] }>()
// Why: preserve insertion order so threads appear in the order their first
// comment was created (the comments array is already sorted by createdAt).
const threadOrder: string[] = []
for (const comment of comments) {
if (!comment.threadId) {
groups.push({ kind: 'standalone', comment })
continue
}
const existing = threadMap.get(comment.threadId)
if (existing) {
existing.replies.push(comment)
} else {
threadMap.set(comment.threadId, { root: comment, replies: [] })
threadOrder.push(comment.threadId)
}
}
// Interleave threads at the position of their first comment.
// Walk the original comment list and emit each thread/standalone once.
const emitted = new Set<string>()
const result: CommentGroup[] = []
for (const comment of comments) {
if (!comment.threadId) {
result.push({ kind: 'standalone', comment })
} else if (!emitted.has(comment.threadId)) {
emitted.add(comment.threadId)
const thread = threadMap.get(comment.threadId)!
result.push({ kind: 'thread', threadId: comment.threadId, ...thread })
}
}
return result
}
/** Renders the PR comments section below checks. */
export function PRCommentsList({
comments,
commentsLoading,
onResolve
}: {
comments: PRComment[]
commentsLoading: boolean
onResolve?: (threadId: string, resolve: boolean) => void
}): React.JSX.Element {
const groups = React.useMemo(() => groupComments(comments), [comments])
return (
<div className="border-t border-border">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<MessageSquare className="size-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium text-foreground">Comments</span>
{comments.length > 0 && (
<span className="text-[10px] text-muted-foreground">{comments.length}</span>
)}
</div>
{/* List */}
{commentsLoading && comments.length === 0 ? (
<div className="flex items-center justify-center py-6">
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
</div>
) : comments.length === 0 ? (
<div className="flex items-center justify-center py-6 text-[11px] text-muted-foreground">
No comments
</div>
) : (
<div className="py-1">
{groups.map((group) => {
if (group.kind === 'standalone') {
return (
<CommentRow
key={group.comment.id}
comment={group.comment}
isReply={false}
showResolve={false}
onResolve={onResolve}
/>
)
}
return (
<div key={group.threadId} className="py-0.5">
<CommentRow
comment={group.root}
isReply={false}
showResolve={true}
onResolve={onResolve}
/>
{group.replies.length > 0 && (
<div className="ml-3 border-l-2 border-border/50">
{group.replies.map((reply) => (
<CommentRow
key={reply.id}
comment={reply}
isReply={true}
showResolve={false}
onResolve={onResolve}
/>
))}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
export function prStateColor(state: PRInfo['state']): string {
switch (state) {
case 'merged':

View File

@ -1,6 +1,14 @@
/* eslint-disable max-lines -- Why: the GitHub slice co-locates all cache + fetch logic for
PR, issue, checks, and comments data so the dedup and invalidation patterns stay consistent. */
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { PRInfo, IssueInfo, PRCheckDetail, Worktree } from '../../../../shared/types'
import type {
PRInfo,
IssueInfo,
PRCheckDetail,
PRComment,
Worktree
} from '../../../../shared/types'
import { syncPRChecksStatus } from './github-checks'
export type CacheEntry<T> = {
@ -21,6 +29,7 @@ const inflightPRRequests = new Map<
>()
const inflightIssueRequests = new Map<string, Promise<IssueInfo | null>>()
const inflightChecksRequests = new Map<string, Promise<PRCheckDetail[]>>()
const inflightCommentsRequests = new Map<string, Promise<PRComment[]>>()
const prRequestGenerations = new Map<string, number>()
function isFresh<T>(entry: CacheEntry<T> | undefined, ttl = CACHE_TTL): entry is CacheEntry<T> {
@ -48,6 +57,7 @@ export type GitHubSlice = {
prCache: Record<string, CacheEntry<PRInfo>>
issueCache: Record<string, CacheEntry<IssueInfo>>
checksCache: Record<string, CacheEntry<PRCheckDetail[]>>
commentsCache: Record<string, CacheEntry<PRComment[]>>
fetchPRForBranch: (
repoPath: string,
branch: string,
@ -61,6 +71,17 @@ export type GitHubSlice = {
headSha?: string,
options?: FetchOptions
) => Promise<PRCheckDetail[]>
fetchPRComments: (
repoPath: string,
prNumber: number,
options?: FetchOptions
) => Promise<PRComment[]>
resolveReviewThread: (
repoPath: string,
prNumber: number,
threadId: string,
resolve: boolean
) => Promise<boolean>
initGitHubCache: () => Promise<void>
refreshAllGitHub: () => void
refreshGitHubForWorktree: (worktreeId: string) => void
@ -70,6 +91,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
prCache: {},
issueCache: {},
checksCache: {},
commentsCache: {},
initGitHubCache: async () => {
try {
@ -223,9 +245,78 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
return request
},
fetchPRComments: async (repoPath, prNumber, options): Promise<PRComment[]> => {
const cacheKey = `${repoPath}::pr-comments::${prNumber}`
const cached = get().commentsCache[cacheKey]
if (!options?.force && isFresh(cached)) {
return cached.data ?? []
}
const inflightRequest = inflightCommentsRequests.get(cacheKey)
if (inflightRequest) {
return inflightRequest
}
const request = (async () => {
try {
const comments = (await window.api.gh.prComments({
repoPath,
prNumber,
noCache: options?.force
})) as PRComment[]
set((s) => ({
commentsCache: {
...s.commentsCache,
[cacheKey]: { data: comments, fetchedAt: Date.now() }
}
}))
return comments
} catch (err) {
console.error('Failed to fetch PR comments:', err)
return get().commentsCache[cacheKey]?.data ?? []
} finally {
inflightCommentsRequests.delete(cacheKey)
}
})()
inflightCommentsRequests.set(cacheKey, request)
return request
},
resolveReviewThread: async (repoPath, prNumber, threadId, resolve) => {
const cacheKey = `${repoPath}::pr-comments::${prNumber}`
// Optimistic update: toggle isResolved on all comments in this thread immediately
// so the UI feels instant. Reverts if the API call fails.
const prev = get().commentsCache[cacheKey]?.data
if (prev) {
set((s) => ({
commentsCache: {
...s.commentsCache,
[cacheKey]: {
...s.commentsCache[cacheKey],
data: prev.map((c) => (c.threadId === threadId ? { ...c, isResolved: resolve } : c))
}
}
}))
}
const ok = await window.api.gh.resolveReviewThread({ repoPath, threadId, resolve })
if (!ok && prev) {
// Revert optimistic update on failure
set((s) => ({
commentsCache: {
...s.commentsCache,
[cacheKey]: { ...s.commentsCache[cacheKey], data: prev }
}
}))
}
return ok
},
refreshAllGitHub: () => {
// Invalidate checks cache so it refreshes on next access
set({ checksCache: {} })
// Invalidate checks and comments caches so they refresh on next access
set({ checksCache: {}, commentsCache: {} })
// Only re-fetch PR/issue entries that are already stale — skip fresh ones
const state = get()

View File

@ -197,6 +197,26 @@ export type PRCheckDetail = {
url: string | null
}
export type PRComment = {
id: number
author: string
authorAvatarUrl: string
body: string
createdAt: string
url: string
/** File path for inline review comments (absent for top-level conversation comments). */
path?: string
/** GraphQL node ID of the review thread present only for inline review comments.
* Used to resolve/unresolve the thread via GitHub's GraphQL API. */
threadId?: string
/** Whether the review thread has been resolved. Only meaningful when threadId is set. */
isResolved?: boolean
/** End line of the review annotation (1-based). */
line?: number
/** Start line of the review annotation range (1-based). Absent for single-line comments. */
startLine?: number
}
export type IssueInfo = {
number: number
title: string