diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 62cbef92a..1a9f93e6a 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -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 { + 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 { + 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') diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 59e9cd2d4..09864c5e2 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -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 }) => { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index e567fdd6f..bf534658f 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -8,6 +8,7 @@ import type { CreateWorktreeResult, PRInfo, PRCheckDetail, + PRComment, IssueInfo, GlobalSettings, NotificationDispatchRequest, @@ -82,6 +83,16 @@ type GhApi = { headSha?: string noCache?: boolean }) => Promise + prComments: (args: { + repoPath: string + prNumber: number + noCache?: boolean + }) => Promise + resolveReviewThread: (args: { + repoPath: string + threadId: string + resolve: boolean + }) => Promise updatePRTitle: (args: { repoPath: string; prNumber: number; title: string }) => Promise mergePR: (args: { repoPath: string diff --git a/src/preload/index.ts b/src/preload/index.ts index d86bbfed9..79da31bd8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -228,6 +228,18 @@ const api = { noCache?: boolean }): Promise => ipcRenderer.invoke('gh:prChecks', args), + prComments: (args: { + repoPath: string + prNumber: number + noCache?: boolean + }): Promise => ipcRenderer.invoke('gh:prComments', args), + + resolveReviewThread: (args: { + repoPath: string + threadId: string + resolve: boolean + }): Promise => ipcRenderer.invoke('gh:resolveReviewThread', args), + updatePRTitle: (args: { repoPath: string prNumber: number diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 163780345..69638e720 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -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([]) const [checksLoading, setChecksLoading] = useState(false) + const [comments, setComments] = useState([]) + 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) && ( )} + ) } diff --git a/src/renderer/src/components/right-sidebar/checks-helpers.tsx b/src/renderer/src/components/right-sidebar/checks-helpers.tsx index 15895e22c..bab601c88 100644 --- a/src/renderer/src/components/right-sidebar/checks-helpers.tsx +++ b/src/renderer/src/components/right-sidebar/checks-helpers.tsx @@ -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 ( + + ) +} + +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 + } + + return ( + + ) +} + +/** 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 ( +
{ + if (comment.url) { + window.api.shell.openUrl(comment.url) + } + }} + > +
+ {/* Author line: avatar + name + file badge aligned on center */} +
+ {comment.authorAvatarUrl ? ( + {comment.author} + ) : ( +
+ )} + + {comment.author} + + {!isReply && comment.path && ( + + {comment.path.split('/').pop()} + {formatLineRange(comment) && `:${formatLineRange(comment)}`} + + )} +
+
+ {showResolve && comment.threadId != null && onResolve && ( + + )} + +
+
+ {/* Comment body */} +

+ {comment.body} +

+
+
+ ) +} + +/** 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() + // 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() + 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 ( +
+ {/* Header */} +
+ + Comments + {comments.length > 0 && ( + {comments.length} + )} +
+ + {/* List */} + {commentsLoading && comments.length === 0 ? ( +
+ +
+ ) : comments.length === 0 ? ( +
+ No comments +
+ ) : ( +
+ {groups.map((group) => { + if (group.kind === 'standalone') { + return ( + + ) + } + return ( +
+ + {group.replies.length > 0 && ( +
+ {group.replies.map((reply) => ( + + ))} +
+ )} +
+ ) + })} +
+ )} +
+ ) +} + export function prStateColor(state: PRInfo['state']): string { switch (state) { case 'merged': diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 45507e8bd..338c08708 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -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 = { @@ -21,6 +29,7 @@ const inflightPRRequests = new Map< >() const inflightIssueRequests = new Map>() const inflightChecksRequests = new Map>() +const inflightCommentsRequests = new Map>() const prRequestGenerations = new Map() function isFresh(entry: CacheEntry | undefined, ttl = CACHE_TTL): entry is CacheEntry { @@ -48,6 +57,7 @@ export type GitHubSlice = { prCache: Record> issueCache: Record> checksCache: Record> + commentsCache: Record> fetchPRForBranch: ( repoPath: string, branch: string, @@ -61,6 +71,17 @@ export type GitHubSlice = { headSha?: string, options?: FetchOptions ) => Promise + fetchPRComments: ( + repoPath: string, + prNumber: number, + options?: FetchOptions + ) => Promise + resolveReviewThread: ( + repoPath: string, + prNumber: number, + threadId: string, + resolve: boolean + ) => Promise initGitHubCache: () => Promise refreshAllGitHub: () => void refreshGitHubForWorktree: (worktreeId: string) => void @@ -70,6 +91,7 @@ export const createGitHubSlice: StateCreator = (s prCache: {}, issueCache: {}, checksCache: {}, + commentsCache: {}, initGitHubCache: async () => { try { @@ -223,9 +245,78 @@ export const createGitHubSlice: StateCreator = (s return request }, + fetchPRComments: async (repoPath, prNumber, options): Promise => { + 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() diff --git a/src/shared/types.ts b/src/shared/types.ts index c2c9e2afc..8c0c07373 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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