mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
Split github client (#15214)
* refactor(github-client): reorganize client into lifecycle folders * refactor(github-client): extract PR refresh data and outcome assembly Separate the derived data calculation and outcome assembly logic from branch-lookup-resolution into dedicated modules for better separation of concerns. Modernize type import syntax and format exports consistently. * refactor(github-client): improve error handling and resilience Defensive GraphQL parsing prevents partial responses from breaking REST fallbacks. Cache failures now use shorter TTLs for faster recovery. PR operations have dedicated error classification. GraphQL mutations track rate limit usage to prevent quota exhaustion. Data validation improved to reject spurious values. * Extract check rerun error classification with operation context Create classifyRerunChecksError() to provide operation-specific error messages when check reruns fail. This replaces generic GitHub error copy with context appropriate to what the user attempted (rerun checks). Follows the pattern of classifyListPrsError and improves error handling by delegating extraction to extractExecError. * Make check-rerun not-found error message resource-neutral Error handling for failed check reruns now covers both workflow-run reruns and standalone check-run rerequests. Tests verify the neutral message works for both scenarios.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import type { GitHubViewer } from '../../../../shared/github/pull-request-types'
|
||||
import { execFileAsync, acquire, release } from '../../gh-utils'
|
||||
/**
|
||||
* Get the authenticated GitHub viewer when gh is available and logged in.
|
||||
* Returns null when gh is unavailable, unauthenticated, or the lookup fails.
|
||||
*/
|
||||
export async function getAuthenticatedViewer(): Promise<GitHubViewer | null> {
|
||||
await acquire()
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'gh',
|
||||
['api', 'user', '--jq', '{login: .login, email: .email}'],
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
const viewer = JSON.parse(stdout) as { login?: string; email?: string | null }
|
||||
if (!viewer.login?.trim()) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
login: viewer.login.trim(),
|
||||
email: viewer.email?.trim() || null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import type { PRComment } from '../../../../shared/github/comment-types'
|
||||
import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items'
|
||||
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from '../../comment-reactions'
|
||||
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit'
|
||||
import { assertRateLimitBudget } from './../lookup/pr-lookup-rate-limit'
|
||||
import { REVIEW_THREADS_QUERY } from './pr-review-threads-query'
|
||||
/**
|
||||
* 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; 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)
|
||||
}
|
||||
if (ownerRepo) {
|
||||
await assertRateLimitBudget('core', ownerRepo, ghOptions)
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
if (ownerRepo) {
|
||||
// Why: --cache 60s saves rate-limit budget on normal loads; explicit refresh skips it for fresh data.
|
||||
const cacheArgs = options?.noCache ? [] : ['--cache', '60s']
|
||||
const base = `repos/${ownerRepo.owner}/${ownerRepo.repo}`
|
||||
|
||||
// Why: allSettled so one failing endpoint doesn't blank out all comments; failed sources contribute zero.
|
||||
const reviewThreadsGuard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
|
||||
let reviewThreadsFetch: Promise<{ stdout: string; stderr: string } | null>
|
||||
if (reviewThreadsGuard.blocked) {
|
||||
reviewThreadsFetch = Promise.resolve(null)
|
||||
} else {
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
|
||||
reviewThreadsFetch = ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'graphql',
|
||||
'-f',
|
||||
`query=${REVIEW_THREADS_QUERY}`,
|
||||
'-f',
|
||||
`owner=${ownerRepo.owner}`,
|
||||
'-f',
|
||||
`repo=${ownerRepo.repo}`,
|
||||
'-F',
|
||||
`pr=${prNumber}`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
}
|
||||
const [issueResult, threadsResult, reviewsResult] = await Promise.allSettled([
|
||||
ghExecFileAsync(
|
||||
['api', ...cacheArgs, `${base}/issues/${prNumber}/comments?per_page=100`],
|
||||
ghOptions
|
||||
),
|
||||
reviewThreadsFetch,
|
||||
// Why: review summaries (approve/request-changes/general) live under pulls/{n}/reviews, not issue comments or threads.
|
||||
ghExecFileAsync(
|
||||
['api', ...cacheArgs, `${base}/pulls/${prNumber}/reviews?per_page=100`],
|
||||
ghOptions
|
||||
)
|
||||
])
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions)
|
||||
|
||||
// Parse issue comments (REST)
|
||||
type RESTComment = {
|
||||
id: number
|
||||
user: { login: string; avatar_url: string; type?: 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,
|
||||
isBot: c.user?.type === 'Bot'
|
||||
})
|
||||
)
|
||||
} 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: {
|
||||
id: string
|
||||
databaseId: number
|
||||
author: { __typename?: string; login: string; avatarUrl: string } | null
|
||||
body: string
|
||||
createdAt: string
|
||||
url: string
|
||||
path: string
|
||||
reactionGroups?: GitHubGraphQLReactionGroup[] | null
|
||||
}[]
|
||||
}
|
||||
}
|
||||
type GQLIssueComment = {
|
||||
id: string
|
||||
databaseId: number
|
||||
author: { __typename?: string; login: string; avatarUrl: string } | null
|
||||
body: string
|
||||
createdAt: string
|
||||
url: string
|
||||
reactionGroups?: GitHubGraphQLReactionGroup[] | null
|
||||
}
|
||||
let graphQLReviewSummaries: PRComment[] | undefined
|
||||
const reviewComments: PRComment[] = []
|
||||
if (threadsResult.status === 'fulfilled' && threadsResult.value) {
|
||||
const threadsData = JSON.parse(threadsResult.value.stdout) as {
|
||||
data?: {
|
||||
repository?: {
|
||||
pullRequest?: {
|
||||
reviewThreads?: { nodes?: GQLThread[] | null } | null
|
||||
comments?: { nodes?: GQLIssueComment[] | null } | null
|
||||
reviews?: { nodes?: GQLIssueComment[] | null } | null
|
||||
} | null
|
||||
} | null
|
||||
} | null
|
||||
}
|
||||
// Why: graphql can exit 0 with data.repository null plus an errors array (scopes, field-level denial);
|
||||
// dereferencing it would throw and drop the REST halves fetched alongside it.
|
||||
const pullRequest = threadsData.data?.repository?.pullRequest
|
||||
if (!pullRequest) {
|
||||
console.warn('Review threads response missing pullRequest; keeping REST results')
|
||||
}
|
||||
const graphQLIssueComments = (pullRequest?.comments?.nodes ?? []).map(
|
||||
(c): PRComment => ({
|
||||
id: c.databaseId,
|
||||
author: c.author?.login ?? 'ghost',
|
||||
authorAvatarUrl: c.author?.avatarUrl ?? '',
|
||||
body: c.body ?? '',
|
||||
createdAt: c.createdAt,
|
||||
url: c.url,
|
||||
isBot: c.author?.__typename === 'Bot',
|
||||
reactionSubjectId: c.id,
|
||||
reactions: mapGraphQLReactionGroups(c.reactionGroups)
|
||||
})
|
||||
)
|
||||
if (graphQLIssueComments.length > 0) {
|
||||
issueComments = graphQLIssueComments
|
||||
}
|
||||
// Why: leave undefined when the payload is incomplete so the REST review summaries stay in use.
|
||||
graphQLReviewSummaries = pullRequest
|
||||
? (pullRequest.reviews?.nodes ?? [])
|
||||
.filter((review) => review.body?.trim())
|
||||
.map(
|
||||
(review): PRComment => ({
|
||||
id: review.databaseId,
|
||||
author: review.author?.login ?? 'ghost',
|
||||
authorAvatarUrl: review.author?.avatarUrl ?? '',
|
||||
body: review.body,
|
||||
createdAt: review.createdAt,
|
||||
url: review.url,
|
||||
isBot: review.author?.__typename === 'Bot',
|
||||
reactionSubjectId: review.id,
|
||||
reactions: mapGraphQLReactionGroups(review.reactionGroups)
|
||||
})
|
||||
)
|
||||
: undefined
|
||||
|
||||
const threads = 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,
|
||||
isBot: c.author?.__typename === 'Bot',
|
||||
reactionSubjectId: c.id,
|
||||
reactions: mapGraphQLReactionGroups(c.reactionGroups),
|
||||
path: c.path,
|
||||
threadId: thread.id,
|
||||
isResolved: thread.isResolved,
|
||||
isOutdated: thread.line == null,
|
||||
// Why: GitHub nulls line/startLine when the commented code is outdated (e.g. force-push); originalLine preserves the original numbers.
|
||||
line: thread.line ?? thread.originalLine ?? undefined,
|
||||
startLine: thread.startLine ?? thread.originalStartLine ?? undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (threadsResult.status === 'rejected') {
|
||||
console.warn('Failed to fetch review threads:', threadsResult.reason)
|
||||
}
|
||||
}
|
||||
|
||||
// Review summaries (REST); skip empty-body reviews (e.g. approvals with no comment) as noise.
|
||||
type RESTReview = {
|
||||
id: number
|
||||
user: { login: string; avatar_url: string; type?: string } | null
|
||||
body: string
|
||||
state: string
|
||||
submitted_at: string
|
||||
html_url: string
|
||||
}
|
||||
let reviewSummaries: PRComment[] = []
|
||||
if (graphQLReviewSummaries) {
|
||||
reviewSummaries = graphQLReviewSummaries
|
||||
} else 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,
|
||||
isBot: r.user?.type === 'Bot'
|
||||
})
|
||||
)
|
||||
} 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 ghExecFileAsync(
|
||||
['pr', 'view', String(prNumber), '--json', 'comments'],
|
||||
ghOptions
|
||||
)
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { IssueSourcePreference } from '../../../../shared/repo-types'
|
||||
import { acquire, release, classifyGhError, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import {
|
||||
resolveGitHubApiRepository,
|
||||
resolveGitHubApiRepositoryCandidates,
|
||||
resolveIssueGitHubApiRepositorySource,
|
||||
type GitHubApiRepository
|
||||
} from '../../github-api-repository'
|
||||
import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key'
|
||||
import type { MainWorkItem } from './../map/work-item-field-coercion'
|
||||
import {
|
||||
fetchIssueWorkItem,
|
||||
fetchPullRequestWorkItem,
|
||||
fetchPullRequestWorkItemFromCandidates
|
||||
} from './work-item-fetch'
|
||||
export async function getWorkItem(
|
||||
repoPath: string,
|
||||
number: number,
|
||||
type?: 'issue' | 'pr',
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference
|
||||
): Promise<MainWorkItem | null> {
|
||||
await acquire()
|
||||
try {
|
||||
// Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number
|
||||
// must share that preference so origin/upstream toggles cannot disagree.
|
||||
if (type === 'issue') {
|
||||
const { source } = await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
// Why: explicit origin with no origin identity must not bare-lookup ambient gh
|
||||
// (same fail-closed rule as origin-pinned PR candidate resolution).
|
||||
if (!source && preference === 'origin') {
|
||||
return null
|
||||
}
|
||||
return await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions)
|
||||
}
|
||||
if (type === 'pr') {
|
||||
return await fetchPullRequestWorkItemFromCandidates(
|
||||
repoPath,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
preference
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { source } = await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (source || preference !== 'origin') {
|
||||
const issue = await fetchIssueWorkItem(
|
||||
repoPath,
|
||||
source,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (issue) {
|
||||
return issue
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Why: only fall through to PR #N on a genuine 404; re-throw transient errors so a flake can't surface an unrelated PR.
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
if (classifyGhError(stderr).type !== 'not_found') {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return await fetchPullRequestWorkItemFromCandidates(
|
||||
repoPath,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
preference
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorkItemByOwnerRepo(
|
||||
repoPath: string,
|
||||
ownerRepo: GitHubApiRepository,
|
||||
number: number,
|
||||
type: 'issue' | 'pr',
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<MainWorkItem | null> {
|
||||
const requestedHost = ownerRepo.host?.trim().toLowerCase()
|
||||
const requestedRepository = requestedHost
|
||||
? { ...ownerRepo, host: requestedHost }
|
||||
: await resolveGitHubApiRepository(repoPath, ownerRepo, connectionId, localGitOptions)
|
||||
if (!requestedRepository) {
|
||||
return null
|
||||
}
|
||||
const { candidates } = await resolveGitHubApiRepositoryCandidates(
|
||||
repoPath,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
const requestedKey = githubRepoIdentityKey(requestedRepository)
|
||||
const matchedRepository = candidates.find(
|
||||
(candidate) => githubRepoIdentityKey(candidate) === requestedKey
|
||||
)
|
||||
// Why: this lookup is reachable from pasted links. Restricting it to a
|
||||
// configured remote prevents gh from sending credentials to an arbitrary host.
|
||||
if (!matchedRepository) {
|
||||
return null
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
if (type === 'issue') {
|
||||
return await fetchIssueWorkItem(
|
||||
repoPath,
|
||||
matchedRepository,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
}
|
||||
return await fetchPullRequestWorkItem(
|
||||
repoPath,
|
||||
matchedRepository,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { execFileAsync, acquire, release } from '../../gh-utils'
|
||||
export const ORCA_REPO = 'stablyai/orca'
|
||||
|
||||
/**
|
||||
* Check if the authenticated user has starred the Orca repo.
|
||||
* Returns true if starred, false if not, null if unable to determine (gh unavailable).
|
||||
*/
|
||||
export async function checkOrcaStarred(): Promise<boolean | null> {
|
||||
await acquire()
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
'gh',
|
||||
['api', '--include', `user/starred/${ORCA_REPO}`],
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
const response = `${stdout ?? ''}\n${stderr ?? ''}`
|
||||
if (/HTTP\/\S+\s+(?:200|204)\b/.test(response)) {
|
||||
return true
|
||||
}
|
||||
return null
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
// 404 means the user hasn't starred — the only expected "no" answer
|
||||
if (message.includes('HTTP 404')) {
|
||||
return false
|
||||
}
|
||||
// Anything else (gh not installed, not authenticated, network issue)
|
||||
return null
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Star the Orca repo for the authenticated user.
|
||||
*/
|
||||
export async function starOrca(): Promise<boolean> {
|
||||
await acquire()
|
||||
try {
|
||||
await execFileAsync('gh', ['api', '-X', 'PUT', `user/starred/${ORCA_REPO}`], {
|
||||
encoding: 'utf-8'
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Why: review thread resolution status + thread IDs are GraphQL-only (REST pulls/{n}/comments omits them).
|
||||
export 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 {
|
||||
id
|
||||
databaseId
|
||||
author { __typename login avatarUrl(size: 48) }
|
||||
body
|
||||
createdAt
|
||||
url
|
||||
path
|
||||
reactionGroups {
|
||||
content
|
||||
viewerHasReacted
|
||||
reactors {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
author { __typename login avatarUrl(size: 48) }
|
||||
body
|
||||
createdAt
|
||||
url
|
||||
reactionGroups {
|
||||
content
|
||||
viewerHasReacted
|
||||
reactors {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
reviews(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
author { __typename login avatarUrl(size: 48) }
|
||||
body
|
||||
createdAt
|
||||
url
|
||||
reactionGroups {
|
||||
content
|
||||
viewerHasReacted
|
||||
reactors {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ghExecFileAsync, acquire, release, type OwnerRepo } from '../../gh-utils'
|
||||
import {
|
||||
getHostedReviewLocalGitOptions,
|
||||
type HostedReviewExecutionOptions
|
||||
} from '../../../source-control/hosted-review-git-options'
|
||||
import {
|
||||
getGitHubApiRepositoryForRemote,
|
||||
getOriginGitHubApiRepository,
|
||||
githubRepositorySlugArg,
|
||||
resolveGitHubRepoExecution,
|
||||
type GitHubApiRepository
|
||||
} from '../../github-api-repository'
|
||||
import { hostedReviewLocalGitOptionArgs, sameOwnerRepo } from './../github-exec-scope'
|
||||
export async function getRepoSlug(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
): Promise<GitHubApiRepository | null> {
|
||||
return getOriginGitHubApiRepository(
|
||||
repoPath,
|
||||
connectionId,
|
||||
getHostedReviewLocalGitOptions(options)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a fork's upstream/parent owner/repo, or null when not a fork.
|
||||
* Why: drives the fork indicator, and a same-name fork's avatar prefers the
|
||||
* upstream owner (a renamed fork keeps its own owner).
|
||||
* Best-effort: any failure (offline, unauthed, non-GitHub) resolves to null.
|
||||
*/
|
||||
export async function getRepoUpstream(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
): Promise<OwnerRepo | null> {
|
||||
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
|
||||
const localGitOptions = localGitArgs[0] ?? {}
|
||||
const { ownerRepo: origin, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
undefined,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (!origin) {
|
||||
return null
|
||||
}
|
||||
const upstreamRemote = await getGitHubApiRepositoryForRemote(
|
||||
repoPath,
|
||||
'upstream',
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (upstreamRemote && !sameOwnerRepo(upstreamRemote, origin)) {
|
||||
return upstreamRemote
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
// Why: positional slugs bypass the runner's --repo qualifier, so the slug
|
||||
// itself must carry the Enterprise host.
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['repo', 'view', githubRepositorySlugArg(origin), '--json', 'isFork,parent'],
|
||||
// Why: cap this best-effort add-time lookup so a stalled gh process can't hold up repo creation.
|
||||
{
|
||||
...ghOptions,
|
||||
timeout: 10_000
|
||||
}
|
||||
)
|
||||
const data = JSON.parse(stdout) as {
|
||||
isFork?: boolean
|
||||
parent?: { name?: string; owner?: { login?: string } } | null
|
||||
}
|
||||
const owner = data.parent?.owner?.login
|
||||
const repo = data.parent?.name
|
||||
// Why: a fork parent lives on the same server as the fork.
|
||||
return data.isFork && owner && repo
|
||||
? { owner, repo, ...(origin.host ? { host: origin.host } : {}) }
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { IssueSourcePreference } from '../../../../shared/repo-types'
|
||||
import {
|
||||
ghExecFileAsync,
|
||||
classifyGhError,
|
||||
ghRepoExecOptions,
|
||||
githubRepoContext,
|
||||
type LocalGitExecOptions
|
||||
} from '../../gh-utils'
|
||||
import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import type { GhExecOptions } from './../github-exec-scope'
|
||||
import { resolvePullRequestLookupCandidates } from './../pull-request-lookup-candidates'
|
||||
import { detectRepositoryMergeMetadata } from './../detect/repository-merge-metadata'
|
||||
import {
|
||||
WORK_ITEM_PR_DETAIL_JSON_FIELDS,
|
||||
usersFromUnknown,
|
||||
latestReviewsFromUnknown,
|
||||
type MainWorkItem
|
||||
} from './../map/work-item-field-coercion'
|
||||
import { mapIssueWorkItem, mapPullRequestWorkItem } from './../map/work-item'
|
||||
export async function fetchIssueWorkItem(
|
||||
repoPath: string,
|
||||
ownerRepo: GitHubApiRepository | null,
|
||||
number: number,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<MainWorkItem | null> {
|
||||
const ghOptions = {
|
||||
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
|
||||
...githubHostExecOptions(ownerRepo)
|
||||
}
|
||||
if (ownerRepo) {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${number}`],
|
||||
ghOptions
|
||||
)
|
||||
const item = JSON.parse(stdout) as Record<string, unknown>
|
||||
if ('pull_request' in item) {
|
||||
return null
|
||||
}
|
||||
return mapIssueWorkItem(item)
|
||||
}
|
||||
|
||||
if (connectionId) {
|
||||
// Why: SSH-backed gh has no repository cwd. A bare lookup could honor the
|
||||
// local process GH_REPO/GH_HOST and return an unrelated repository item.
|
||||
return null
|
||||
}
|
||||
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['issue', 'view', String(number), '--json', 'number,title,state,url,labels,updatedAt,author'],
|
||||
ghOptions
|
||||
)
|
||||
return mapIssueWorkItem(JSON.parse(stdout) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
// Why: REST /pulls/{n} lacks latestReviews, so pull review fields from gh so reviewer lists aren't silently empty.
|
||||
export const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews'
|
||||
|
||||
export async function fetchPullRequestReviewFields(
|
||||
number: number,
|
||||
ownerRepo: GitHubApiRepository | null,
|
||||
ghOptions: GhExecOptions
|
||||
): Promise<Pick<MainWorkItem, 'reviewRequests' | 'latestReviews'>> {
|
||||
try {
|
||||
const args = ownerRepo
|
||||
? [
|
||||
'pr',
|
||||
'view',
|
||||
String(number),
|
||||
'--repo',
|
||||
`${ownerRepo.owner}/${ownerRepo.repo}`,
|
||||
'--json',
|
||||
WORK_ITEM_PR_REVIEW_JSON_FIELDS
|
||||
]
|
||||
: ['pr', 'view', String(number), '--json', WORK_ITEM_PR_REVIEW_JSON_FIELDS]
|
||||
const { stdout } = await ghExecFileAsync(args, ghOptions)
|
||||
const item = JSON.parse(stdout) as Record<string, unknown>
|
||||
return {
|
||||
...(item.reviewRequests !== undefined
|
||||
? { reviewRequests: usersFromUnknown(item.reviewRequests) }
|
||||
: {}),
|
||||
...(item.latestReviews !== undefined
|
||||
? { latestReviews: latestReviewsFromUnknown(item.latestReviews) }
|
||||
: {})
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPullRequestWorkItem(
|
||||
repoPath: string,
|
||||
ownerRepo: GitHubApiRepository | null,
|
||||
number: number,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<MainWorkItem | null> {
|
||||
const ghOptions = {
|
||||
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
|
||||
...githubHostExecOptions(ownerRepo)
|
||||
}
|
||||
if (ownerRepo) {
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
String(number),
|
||||
'--repo',
|
||||
`${ownerRepo.owner}/${ownerRepo.repo}`,
|
||||
'--json',
|
||||
WORK_ITEM_PR_DETAIL_JSON_FIELDS
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
const item = JSON.parse(stdout) as Record<string, unknown>
|
||||
const mapped = mapPullRequestWorkItem(item, ownerRepo)
|
||||
// Why: merge-metadata GraphQL is best-effort — don't fall through to REST, which drops latestReviews and blanks bot-only reviewer lists.
|
||||
const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined
|
||||
try {
|
||||
const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions)
|
||||
return {
|
||||
...mapped,
|
||||
mergeQueueRequired: mergeMetadata.mergeQueueRequired,
|
||||
...(mergeMetadata.autoMergeAllowed !== null
|
||||
? { autoMergeAllowed: mergeMetadata.autoMergeAllowed }
|
||||
: {}),
|
||||
...(mergeMetadata.mergeMethodSettings
|
||||
? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
|
||||
: {})
|
||||
}
|
||||
} catch {
|
||||
return mapped
|
||||
}
|
||||
} catch {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
|
||||
ghOptions
|
||||
)
|
||||
const mapped = mapPullRequestWorkItem(
|
||||
JSON.parse(stdout) as Record<string, unknown>,
|
||||
ownerRepo
|
||||
)
|
||||
const reviewFields = await fetchPullRequestReviewFields(number, ownerRepo, ghOptions)
|
||||
return { ...mapped, ...reviewFields }
|
||||
}
|
||||
}
|
||||
|
||||
if (connectionId) {
|
||||
// Why: connection-backed gh cannot infer a repository from cwd. Refuse a
|
||||
// bare call so process-level GH_REPO/GH_HOST cannot redirect the lookup.
|
||||
return null
|
||||
}
|
||||
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['pr', 'view', String(number), '--json', WORK_ITEM_PR_DETAIL_JSON_FIELDS],
|
||||
ghOptions
|
||||
)
|
||||
return mapPullRequestWorkItem(JSON.parse(stdout) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export async function fetchPullRequestWorkItemFromCandidates(
|
||||
repoPath: string,
|
||||
number: number,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference
|
||||
): Promise<MainWorkItem | null> {
|
||||
const candidates = await resolvePullRequestLookupCandidates(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (candidates.length === 0) {
|
||||
if (preference === 'origin') {
|
||||
return null
|
||||
}
|
||||
return fetchPullRequestWorkItem(repoPath, null, number, connectionId, localGitOptions)
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return await fetchPullRequestWorkItem(
|
||||
repoPath,
|
||||
candidate,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const classification = classifyGhError(message).type
|
||||
if (classification !== 'not_found' && classification !== 'permission_denied') {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user