mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
feat: add PR comments to checks panel (#376)
This commit is contained in:
+252
-1
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user