mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
import { GITHUB_BODY_MEDIA_TYPE } from '../../attachments/body-image-urls'
|
|
import type { PRComment } from '../../../../shared/github/comments/comment-types'
|
|
import { runCoalescedProbe, type CoalescedProbes } from '../../../git/remotes/coalesced-probe'
|
|
import { extractExecError } from '../../../git/command-runner/process-capture/exec-error'
|
|
import { codeHostReadExecutionScope as githubReadExecutionScope } from '../../../source-control/code-host-read-execution-scope'
|
|
import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/github/work-items/work-item-ssh-error'
|
|
import { ghExecFileAsync } from '../../../git/command-runner/hosted-cli/gh-exec-file'
|
|
import { acquire, release } from '../../execution/github-operation-slots'
|
|
import type { LocalGitExecOptions } from '../../repositories/github-repository-identity'
|
|
import { resolveGitHubRepoExecution } from '../../repositories/github-api-repository'
|
|
import type { GitHubRepositoryIdentity as GitHubApiRepository } from '../../../../shared/github/repositories/repository-identity'
|
|
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../api/rate-limit'
|
|
import { assertRateLimitBudget } from './../lookup/pr-lookup-rate-limit'
|
|
import { REVIEW_THREADS_QUERY } from './pr-review-threads-query'
|
|
|
|
import {
|
|
type parsePRConversationResponse,
|
|
parseRestPRCommentPage
|
|
} from './pr-conversation-response'
|
|
import {
|
|
readCompletePRConversation,
|
|
IncompletePRConversationError
|
|
} from './pr-conversation-pagination'
|
|
import { readViewerLogin } from './authenticated-viewer'
|
|
/** Combine conversation comments, review summaries and GraphQL threads with resolution metadata. */
|
|
async function readPRComments(
|
|
repoPath: string,
|
|
prNumber: number,
|
|
options?: { noCache?: boolean; prRepo?: GitHubApiRepository | null },
|
|
connectionId?: string | null,
|
|
localGitOptions: LocalGitExecOptions = {}
|
|
): Promise<PRComment[]> {
|
|
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
|
repoPath,
|
|
options?.prRepo,
|
|
connectionId,
|
|
localGitOptions
|
|
)
|
|
if (connectionId && !ownerRepo) {
|
|
throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
|
|
}
|
|
await acquire()
|
|
let restBudgetFailed = false
|
|
try {
|
|
if (ownerRepo) {
|
|
const base = `repos/${ownerRepo.owner}/${ownerRepo.repo}`
|
|
|
|
// Retain successful comment sources when another endpoint fails.
|
|
const reviewThreadsGuard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
|
|
let threadsResult: { stdout: string } | null = null
|
|
if (reviewThreadsGuard.blocked) {
|
|
threadsResult = null
|
|
} else {
|
|
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
|
|
try {
|
|
threadsResult = await ghExecFileAsync(
|
|
[
|
|
'api',
|
|
'graphql',
|
|
'-f',
|
|
`query=${REVIEW_THREADS_QUERY}`,
|
|
'-f',
|
|
`owner=${ownerRepo.owner}`,
|
|
'-f',
|
|
`repo=${ownerRepo.repo}`,
|
|
'-F',
|
|
`pr=${prNumber}`
|
|
],
|
|
ghOptions
|
|
)
|
|
} catch (error) {
|
|
// gh exits nonzero for partial GraphQL errors; keep the successful sources.
|
|
const { stdout, stderr } = extractExecError(error)
|
|
threadsResult = stdout ? { stdout } : null
|
|
console.warn('Failed to fetch review threads:', stderr)
|
|
}
|
|
}
|
|
|
|
let conversation: ReturnType<typeof parsePRConversationResponse> = { reviewComments: [] }
|
|
if (threadsResult) {
|
|
try {
|
|
conversation = await readCompletePRConversation(
|
|
threadsResult.stdout,
|
|
async (query, variables) => {
|
|
await assertRateLimitBudget('graphql', ownerRepo, ghOptions)
|
|
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
|
|
const args = ['api', 'graphql', '-f', `query=${query}`]
|
|
if (!('threadId' in variables)) {
|
|
args.push(
|
|
'-f',
|
|
`owner=${ownerRepo.owner}`,
|
|
'-f',
|
|
`repo=${ownerRepo.repo}`,
|
|
'-F',
|
|
`pr=${prNumber}`
|
|
)
|
|
}
|
|
for (const [key, value] of Object.entries(variables)) {
|
|
args.push('-f', `${key}=${value}`)
|
|
}
|
|
try {
|
|
return (await ghExecFileAsync(args, ghOptions)).stdout
|
|
} catch (error) {
|
|
const { stdout } = extractExecError(error)
|
|
if (stdout) {
|
|
return stdout
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
)
|
|
} catch (error) {
|
|
if (error instanceof IncompletePRConversationError) {
|
|
throw error
|
|
}
|
|
console.warn('Invalid review threads response; falling back to REST')
|
|
}
|
|
}
|
|
const { issueComments, reviewSummaries, reviewComments } = conversation
|
|
|
|
// REST has no viewerDidAuthor, so conversation comments match the reading account's login instead.
|
|
let restViewer: Promise<string | null> | undefined
|
|
let restFailure: unknown
|
|
const readRestSource = async (endpoint: string, reviews: boolean): Promise<PRComment[]> => {
|
|
await assertRateLimitBudget('core', ownerRepo, ghOptions).catch((error: unknown) => {
|
|
restBudgetFailed = true
|
|
throw error
|
|
})
|
|
const comments: PRComment[] = []
|
|
const ids = new Set<number>()
|
|
const rawIds = new Set<number>()
|
|
let page = 1
|
|
try {
|
|
while (true) {
|
|
if (restFailure) {
|
|
throw restFailure
|
|
}
|
|
if (page > 1) {
|
|
await assertRateLimitBudget('core', ownerRepo, ghOptions)
|
|
}
|
|
if (restFailure) {
|
|
throw restFailure
|
|
}
|
|
const needsViewer = !restViewer
|
|
restViewer ??= readViewerLogin(ghOptions)
|
|
noteRepositoryRateLimitSpend(ownerRepo, 'core', needsViewer ? 2 : 1, ghOptions)
|
|
const [{ stdout }, viewer] = await Promise.all([
|
|
ghExecFileAsync(
|
|
[
|
|
'api',
|
|
'-H',
|
|
GITHUB_BODY_MEDIA_TYPE,
|
|
page === 1 ? endpoint : `${endpoint}&page=${page}`
|
|
],
|
|
ghOptions
|
|
),
|
|
restViewer
|
|
])
|
|
const result = parseRestPRCommentPage(stdout, reviews, viewer)
|
|
const advanced = result.rawIds.some((id) => !rawIds.has(id))
|
|
for (const id of result.rawIds) {
|
|
rawIds.add(id)
|
|
}
|
|
for (const comment of result.comments) {
|
|
if (!ids.has(comment.id)) {
|
|
ids.add(comment.id)
|
|
comments.push(comment)
|
|
}
|
|
}
|
|
if (!result.hasMore) {
|
|
return comments
|
|
}
|
|
if (!advanced) {
|
|
throw new Error('GitHub REST conversation page did not advance')
|
|
}
|
|
page += 1
|
|
}
|
|
} catch (error) {
|
|
if (page > 1) {
|
|
restFailure ??= new IncompletePRConversationError(
|
|
'GitHub REST conversation pagination failed',
|
|
{
|
|
cause: error
|
|
}
|
|
)
|
|
throw restFailure
|
|
}
|
|
console.warn('Failed to fetch PR conversation fallback:', extractExecError(error).stderr)
|
|
return []
|
|
}
|
|
}
|
|
|
|
const results = await Promise.allSettled([
|
|
issueComments === undefined
|
|
? readRestSource(`${base}/issues/${prNumber}/comments?per_page=100`, false)
|
|
: null,
|
|
reviewSummaries === undefined
|
|
? readRestSource(`${base}/pulls/${prNumber}/reviews?per_page=100`, true)
|
|
: null
|
|
])
|
|
for (const result of results) {
|
|
if (result.status === 'rejected') {
|
|
throw result.reason
|
|
}
|
|
}
|
|
const [issueResult, reviewsResult] = results.map((result) =>
|
|
result.status === 'fulfilled' ? result.value : null
|
|
)
|
|
const all = [
|
|
...(issueComments ?? issueResult ?? []),
|
|
...reviewComments,
|
|
...(reviewSummaries ?? reviewsResult ?? [])
|
|
]
|
|
return all
|
|
.map((comment) => ({ comment, time: Date.parse(comment.createdAt) }))
|
|
.sort((a, b) => a.time - b.time)
|
|
.map(({ comment }) => comment)
|
|
}
|
|
|
|
// Legacy local fallback returns issue-level comments only.
|
|
const { stdout } = await ghExecFileAsync(
|
|
['pr', 'view', String(prNumber), '--json', 'comments'],
|
|
ghOptions
|
|
)
|
|
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
|
|
const data: {
|
|
comments: {
|
|
author: { login: string }
|
|
body: string
|
|
createdAt: string
|
|
url: string
|
|
viewerDidAuthor?: boolean
|
|
}[]
|
|
} = JSON.parse(stdout)
|
|
return (data.comments ?? []).map((c, i) => ({
|
|
id: i,
|
|
author: c.author?.login ?? 'ghost',
|
|
authorAvatarUrl: '',
|
|
body: c.body ?? '',
|
|
createdAt: c.createdAt,
|
|
url: c.url ?? '',
|
|
...(c.viewerDidAuthor === true ? { viewerDidAuthor: true } : {})
|
|
}))
|
|
} catch (err) {
|
|
if (restBudgetFailed || err instanceof IncompletePRConversationError) {
|
|
throw err
|
|
}
|
|
console.warn('getPRComments failed:', err)
|
|
return []
|
|
} finally {
|
|
release()
|
|
}
|
|
}
|
|
|
|
const commentReads: CoalescedProbes<PRComment[]> = new Map()
|
|
|
|
export function getPRComments(
|
|
repoPath: string,
|
|
prNumber: number,
|
|
options?: { noCache?: boolean; prRepo?: GitHubApiRepository | null },
|
|
connectionId?: string | null,
|
|
localGitOptions: LocalGitExecOptions = {}
|
|
): Promise<PRComment[]> {
|
|
// Forced refreshes often follow a mutation, so they must not join a read that started before it.
|
|
if (options?.noCache) {
|
|
return readPRComments(repoPath, prNumber, options, connectionId, localGitOptions)
|
|
}
|
|
const key = JSON.stringify([
|
|
repoPath,
|
|
prNumber,
|
|
options?.prRepo ?? null,
|
|
connectionId ?? null,
|
|
githubReadExecutionScope(localGitOptions)
|
|
])
|
|
return runCoalescedProbe(commentReads, key, () =>
|
|
readPRComments(repoPath, prNumber, options, connectionId, localGitOptions)
|
|
)
|
|
}
|