feat: add PR comments to checks panel (#376)
This commit is contained in:
parent
755690b5a2
commit
beb62ff580
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue