mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
refactor(github-client): reorganize client into lifecycle folders
This commit is contained in:
@@ -29,7 +29,6 @@ inline src/main/git/repo.ts
|
||||
inline src/main/git/runner.ts
|
||||
inline src/main/git/status.ts
|
||||
inline src/main/git/worktree.ts
|
||||
inline src/main/github/client.ts
|
||||
inline src/main/github/pr-refresh-coordinator.ts
|
||||
inline src/main/github/project-view.ts
|
||||
inline src/main/github/project-view/mutations.ts
|
||||
|
||||
+29
-5591
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
import type { PRCheckRunDetails } from '../../../../shared/github/check-types'
|
||||
export function nullableString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
export function nullableNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
export function mapCheckAnnotations(raw: unknown): PRCheckRunDetails['annotations'] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return []
|
||||
}
|
||||
return raw
|
||||
.filter((annotation): annotation is Record<string, unknown> => Boolean(annotation))
|
||||
.map((annotation) => ({
|
||||
path: nullableString(annotation.path),
|
||||
startLine: nullableNumber(annotation.start_line),
|
||||
endLine: nullableNumber(annotation.end_line),
|
||||
annotationLevel: nullableString(annotation.annotation_level),
|
||||
title: nullableString(annotation.title),
|
||||
message: nullableString(annotation.message) ?? '',
|
||||
rawDetails: nullableString(annotation.raw_details)
|
||||
}))
|
||||
}
|
||||
|
||||
export function mapWorkflowJobs(raw: unknown, checkName?: string): PRCheckRunDetails['jobs'] {
|
||||
if (!raw || typeof raw !== 'object' || !Array.isArray((raw as { jobs?: unknown }).jobs)) {
|
||||
return []
|
||||
}
|
||||
const jobs = (raw as { jobs: unknown[] }).jobs
|
||||
.filter((job): job is Record<string, unknown> => Boolean(job))
|
||||
.map((job) => ({
|
||||
id: nullableNumber(job.id),
|
||||
name: nullableString(job.name) ?? 'Unnamed job',
|
||||
status: nullableString(job.status),
|
||||
conclusion: nullableString(job.conclusion),
|
||||
startedAt: nullableString(job.started_at),
|
||||
completedAt: nullableString(job.completed_at),
|
||||
url: nullableString(job.html_url),
|
||||
logTail: null,
|
||||
steps: Array.isArray(job.steps)
|
||||
? job.steps
|
||||
.filter((step): step is Record<string, unknown> => Boolean(step))
|
||||
.map((step) => ({
|
||||
name: nullableString(step.name) ?? 'Unnamed step',
|
||||
status: nullableString(step.status),
|
||||
conclusion: nullableString(step.conclusion),
|
||||
startedAt: nullableString(step.started_at),
|
||||
completedAt: nullableString(step.completed_at)
|
||||
}))
|
||||
: []
|
||||
}))
|
||||
const exactMatches = checkName ? jobs.filter((job) => job.name === checkName) : []
|
||||
return exactMatches.length > 0 ? exactMatches : jobs
|
||||
}
|
||||
|
||||
export function getWorkflowRunIdFromCheckRun(
|
||||
checkRun: Record<string, unknown> | null
|
||||
): number | undefined {
|
||||
const checkSuite = checkRun?.check_suite
|
||||
if (!checkSuite || typeof checkSuite !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const workflowRun = (checkSuite as { workflow_run?: unknown }).workflow_run
|
||||
if (!workflowRun || typeof workflowRun !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const id = (workflowRun as { id?: unknown }).id
|
||||
return typeof id === 'number' && Number.isSafeInteger(id) ? id : undefined
|
||||
}
|
||||
|
||||
export function parseActionsRunId(url: string | null | undefined): number | undefined {
|
||||
if (!url) {
|
||||
return undefined
|
||||
}
|
||||
const match = /\/actions\/runs\/(\d+)(?:\/|$)/.exec(url)
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
const id = Number(match[1])
|
||||
return Number.isSafeInteger(id) ? id : undefined
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export function rethrowCheckDetailsAbort(signal: AbortSignal | undefined, error: unknown): void {
|
||||
if (signal?.aborted) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function waitForCheckDetailsResolution<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(signal.reason)
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = (settle: () => void): void => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
settle()
|
||||
}
|
||||
const onAbort = (): void => finish(() => reject(signal.reason))
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void operation.then(
|
||||
(value) => finish(() => resolve(value)),
|
||||
(error) => finish(() => reject(error))
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { PRCheckRunDetails } from '../../../../shared/github/check-types'
|
||||
import { sliceCheckLogTail } from '../../../../shared/check-job-log-tail-slice'
|
||||
import { ghExecFileAsync } from '../../gh-utils'
|
||||
import { type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key'
|
||||
import type { GhExecOptions } from './../github-exec-scope'
|
||||
import { rethrowCheckDetailsAbort } from './check-details-abort'
|
||||
export const PR_CHECK_LOG_TAIL_JOB_LIMIT = 5
|
||||
|
||||
// Why: each entry holds up to 16KB of log text; bound the cache so a long session can't grow it unbounded.
|
||||
export const PR_CHECK_LOG_TAIL_CACHE_MAX_ENTRIES = 128
|
||||
|
||||
export const prCheckLogTailCache = new Map<string, string | null>()
|
||||
|
||||
export function setPrCheckLogTailCache(cacheKey: string, logTail: string | null): void {
|
||||
prCheckLogTailCache.set(cacheKey, logTail)
|
||||
while (prCheckLogTailCache.size > PR_CHECK_LOG_TAIL_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = prCheckLogTailCache.keys().next().value
|
||||
if (oldestKey === undefined) {
|
||||
break
|
||||
}
|
||||
prCheckLogTailCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function isCheckJobFailureState(state: string | null | undefined): boolean {
|
||||
return (
|
||||
state === 'failure' ||
|
||||
state === 'failed' ||
|
||||
state === 'action_required' ||
|
||||
state === 'cancelled' ||
|
||||
state === 'stale' ||
|
||||
state === 'startup_failure' ||
|
||||
state === 'timed_out'
|
||||
)
|
||||
}
|
||||
|
||||
export function getCheckJobLogTailCacheKey(job: PRCheckRunDetails['jobs'][number]): string | null {
|
||||
if (job.id === null) {
|
||||
return null
|
||||
}
|
||||
return `${job.id}:${job.completedAt ?? ''}`
|
||||
}
|
||||
|
||||
export async function attachFailedJobLogTails(
|
||||
jobs: PRCheckRunDetails['jobs'],
|
||||
ownerRepo: GitHubApiRepository,
|
||||
ghOptions: GhExecOptions
|
||||
): Promise<void> {
|
||||
const failedJobs = jobs
|
||||
.filter((job) => {
|
||||
const state = job.conclusion ?? job.status
|
||||
return job.id !== null && isCheckJobFailureState(state)
|
||||
})
|
||||
.slice(0, PR_CHECK_LOG_TAIL_JOB_LIMIT)
|
||||
|
||||
// Why: cap log fetches so failed-job details stay a bounded follow-up, not a burst of hosted log downloads.
|
||||
for (const job of failedJobs) {
|
||||
const jobCacheKey = getCheckJobLogTailCacheKey(job)
|
||||
const cacheKey = jobCacheKey ? `${githubRepoIdentityKey(ownerRepo)}:${jobCacheKey}` : null
|
||||
if (!cacheKey) {
|
||||
continue
|
||||
}
|
||||
if (prCheckLogTailCache.has(cacheKey)) {
|
||||
job.logTail = prCheckLogTailCache.get(cacheKey) ?? null
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/jobs/${job.id}/logs`],
|
||||
ghOptions
|
||||
)
|
||||
job.logTail = sliceCheckLogTail(stdout)
|
||||
} catch (err) {
|
||||
rethrowCheckDetailsAbort(ghOptions.signal, err)
|
||||
console.warn('getPRCheckDetails workflow job log fetch failed:', err)
|
||||
job.logTail = null
|
||||
}
|
||||
setPrCheckLogTailCache(cacheKey, job.logTail)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { PRCheckRunDetails } from '../../../../shared/github/check-types'
|
||||
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS, GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE } from '../../../../shared/github/check-details-deadline'
|
||||
import type { GhExecOptions } from './../github-exec-scope'
|
||||
import { nullableString, mapCheckAnnotations, mapWorkflowJobs, getWorkflowRunIdFromCheckRun } from './check-detail-field-mapping'
|
||||
import { rethrowCheckDetailsAbort, waitForCheckDetailsResolution } from './check-details-abort'
|
||||
import { attachFailedJobLogTails } from './check-job-log-tails'
|
||||
export async function getPRCheckDetails(
|
||||
repoPath: string,
|
||||
args: {
|
||||
checkRunId?: number
|
||||
workflowRunId?: number
|
||||
checkName?: string
|
||||
url?: string | null
|
||||
prRepo?: GitHubApiRepository | null
|
||||
},
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
callerSignal?: AbortSignal
|
||||
): Promise<PRCheckRunDetails | null> {
|
||||
const controller = new AbortController()
|
||||
let hostDeadlineExpired = false
|
||||
const forwardCallerAbort = (): void => controller.abort(callerSignal?.reason)
|
||||
if (callerSignal?.aborted) {
|
||||
forwardCallerAbort()
|
||||
} else {
|
||||
callerSignal?.addEventListener('abort', forwardCallerAbort, { once: true })
|
||||
}
|
||||
const hostDeadline = setTimeout(() => {
|
||||
hostDeadlineExpired = true
|
||||
controller.abort(new Error(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE))
|
||||
}, GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS)
|
||||
let acquired = false
|
||||
try {
|
||||
const resolved = await waitForCheckDetailsResolution(
|
||||
resolveGitHubRepoExecution(repoPath, args.prRepo, connectionId, localGitOptions),
|
||||
controller.signal
|
||||
)
|
||||
if (!resolved.ownerRepo) {
|
||||
return null
|
||||
}
|
||||
const ownerRepo = resolved.ownerRepo
|
||||
const ghOptions: GhExecOptions = { ...resolved.ghOptions, signal: controller.signal }
|
||||
await acquire(controller.signal)
|
||||
acquired = true
|
||||
let checkRun: Record<string, unknown> | null = null
|
||||
let annotations: PRCheckRunDetails['annotations'] = []
|
||||
if (args.checkRunId) {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${args.checkRunId}`],
|
||||
ghOptions
|
||||
)
|
||||
checkRun = JSON.parse(stdout) as Record<string, unknown>
|
||||
try {
|
||||
const annotationsResult = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${args.checkRunId}/annotations?per_page=20`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
annotations = mapCheckAnnotations(JSON.parse(annotationsResult.stdout))
|
||||
} catch (err) {
|
||||
rethrowCheckDetailsAbort(controller.signal, err)
|
||||
console.warn('getPRCheckDetails annotations fetch failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const workflowRunId = args.workflowRunId ?? getWorkflowRunIdFromCheckRun(checkRun)
|
||||
let jobs: PRCheckRunDetails['jobs'] = []
|
||||
if (workflowRunId) {
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${workflowRunId}/jobs?per_page=100`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
jobs = mapWorkflowJobs(JSON.parse(stdout), args.checkName)
|
||||
await attachFailedJobLogTails(jobs, ownerRepo, ghOptions)
|
||||
} catch (err) {
|
||||
rethrowCheckDetailsAbort(controller.signal, err)
|
||||
console.warn('getPRCheckDetails workflow jobs fetch failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const output =
|
||||
checkRun?.output && typeof checkRun.output === 'object'
|
||||
? (checkRun.output as Record<string, unknown>)
|
||||
: null
|
||||
return {
|
||||
name: nullableString(checkRun?.name) ?? args.checkName ?? 'Check',
|
||||
status: nullableString(checkRun?.status),
|
||||
conclusion: nullableString(checkRun?.conclusion),
|
||||
url: nullableString(checkRun?.html_url) ?? args.url ?? null,
|
||||
detailsUrl: nullableString(checkRun?.details_url) ?? args.url ?? null,
|
||||
startedAt: nullableString(checkRun?.started_at),
|
||||
completedAt: nullableString(checkRun?.completed_at),
|
||||
title: nullableString(output?.title),
|
||||
summary: nullableString(output?.summary),
|
||||
text: nullableString(output?.text),
|
||||
annotations,
|
||||
jobs
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('getPRCheckDetails failed:', err)
|
||||
if (hostDeadlineExpired && !callerSignal?.aborted) {
|
||||
throw new Error(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE)
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
clearTimeout(hostDeadline)
|
||||
callerSignal?.removeEventListener('abort', forwardCallerAbort)
|
||||
if (acquired) {
|
||||
release()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { PRCheckDetail } from '../../../../shared/github/check-types'
|
||||
import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items'
|
||||
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { extractExecError } from '../../../git/exec-error'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { mapCheckStatus, mapCheckConclusion } from '../../mappers'
|
||||
import { noteRepositoryRateLimitSpend } from '../../rate-limit'
|
||||
import type { GhExecOptions } from './../github-exec-scope'
|
||||
import { assertRateLimitBudget } from './../lookup/pr-lookup-rate-limit'
|
||||
import { PR_CHECKS_ROLLUP_QUERY, type GraphQLPRChecksResponse, type RestCheckRun, type RestCommitStatus, type RestCheckSuite } from './pr-checks-graphql-query'
|
||||
import { mapRestCheckRun, mapRestCommitStatus, mapGraphQLPRChecksResponse, getPendingApprovalCheckSuiteName, getPendingApprovalCheckSuiteUrl } from './pr-checks-response-mapping'
|
||||
import { parseActionsRunId } from './check-detail-field-mapping'
|
||||
export async function getPRChecksViaRestFallback(
|
||||
ownerRepo: GitHubApiRepository,
|
||||
headSha: string | undefined,
|
||||
ghOptions: GhExecOptions,
|
||||
noCache?: boolean
|
||||
): Promise<PRCheckDetail[] | null> {
|
||||
if (!headSha) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
await assertRateLimitBudget('core', ownerRepo, ghOptions)
|
||||
} catch (err) {
|
||||
console.warn('getPRChecks skipped REST fallback, falling back to gh pr checks:', err)
|
||||
return null
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const cacheArgs = noCache ? [] : ['--cache', '60s']
|
||||
const encodedHeadSha = encodeURIComponent(headSha)
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...cacheArgs,
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/check-runs?per_page=100`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions)
|
||||
const checkRunData = JSON.parse(stdout) as {
|
||||
check_runs?: RestCheckRun[]
|
||||
}
|
||||
const checkRuns = (checkRunData.check_runs ?? []).map(mapRestCheckRun)
|
||||
const checkRunNames = new Set(checkRuns.map((check) => check.name))
|
||||
|
||||
let legacyStatuses: PRCheckDetail[] = []
|
||||
try {
|
||||
const statusResult = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...cacheArgs,
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/status?per_page=100`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions)
|
||||
const statusData = JSON.parse(statusResult.stdout) as {
|
||||
statuses?: RestCommitStatus[]
|
||||
}
|
||||
legacyStatuses = (statusData.statuses ?? [])
|
||||
.map(mapRestCommitStatus)
|
||||
.filter((check): check is PRCheckDetail => check !== null && !checkRunNames.has(check.name))
|
||||
} catch (err) {
|
||||
// Why: REST fallback is already degraded; keep the richer check-run rows if legacy-status enrichment fails.
|
||||
console.warn('getPRChecks REST status fallback failed:', err)
|
||||
}
|
||||
|
||||
let pendingApprovalChecks: PRCheckDetail[] = []
|
||||
try {
|
||||
const suitesResult = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...cacheArgs,
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/check-suites?per_page=100`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions)
|
||||
const suitesData = JSON.parse(suitesResult.stdout) as {
|
||||
check_suites?: RestCheckSuite[]
|
||||
}
|
||||
pendingApprovalChecks = (suitesData.check_suites ?? [])
|
||||
.filter((suite) => suite.conclusion?.toLowerCase() === 'action_required')
|
||||
.map((suite, index) => ({
|
||||
name: getPendingApprovalCheckSuiteName(suite, headSha, index),
|
||||
status: 'completed' as const,
|
||||
conclusion: 'action_required' as const,
|
||||
url: getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.id)
|
||||
}))
|
||||
} catch (err) {
|
||||
console.warn('getPRChecks REST check-suite fallback failed:', err)
|
||||
}
|
||||
|
||||
const checks = [...checkRuns, ...legacyStatuses, ...pendingApprovalChecks]
|
||||
return checks.length > 0 ? checks : null
|
||||
} catch (err) {
|
||||
console.warn('getPRChecks via REST fallback failed, falling back to gh pr checks:', err)
|
||||
return null
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed check statuses for a PR.
|
||||
* Uses GitHub's combined GraphQL rollup so check runs and legacy commit statuses
|
||||
* arrive in one cached request; suite-only approval blockers are included too.
|
||||
*/
|
||||
export async function getPRChecks(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
headSha?: string,
|
||||
prRepo?: GitHubApiRepository | null,
|
||||
options?: { noCache?: boolean },
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<PRCheckDetail[]> {
|
||||
void headSha
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
prRepo,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (connectionId && !ownerRepo) {
|
||||
throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
|
||||
}
|
||||
const fallbackToPRChecks = async (): Promise<PRCheckDetail[]> => {
|
||||
await assertRateLimitBudget('graphql', ownerRepo, ghOptions)
|
||||
await acquire()
|
||||
try {
|
||||
const fallbackArgs = ['pr', 'checks', String(prNumber), '--json', 'name,state,link']
|
||||
if (ownerRepo) {
|
||||
fallbackArgs.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
|
||||
}
|
||||
const { stdout } = await ghExecFileAsync(fallbackArgs, ghOptions).catch((err: unknown) => {
|
||||
const { stderr } = extractExecError(err)
|
||||
// Why: `gh pr checks` exits non-zero when a PR has no check runs yet; treat that as empty, not a load failure.
|
||||
if (stderr.toLowerCase().includes('no checks reported')) {
|
||||
return { stdout: '[]', stderr }
|
||||
}
|
||||
throw err
|
||||
})
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
|
||||
const data = JSON.parse(stdout) as { name: string; state: string; link: string }[]
|
||||
return data.map((d) => ({
|
||||
name: d.name,
|
||||
status: mapCheckStatus(d.state),
|
||||
conclusion: mapCheckConclusion(d.state),
|
||||
url: d.link || null,
|
||||
workflowRunId: parseActionsRunId(d.link)
|
||||
}))
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
if (ownerRepo) {
|
||||
let canUseGraphQLRollup = true
|
||||
try {
|
||||
await assertRateLimitBudget('graphql', ownerRepo, ghOptions)
|
||||
} catch (err) {
|
||||
canUseGraphQLRollup = false
|
||||
console.warn('getPRChecks skipped GraphQL rollup, falling back to gh pr checks:', err)
|
||||
}
|
||||
if (canUseGraphQLRollup) {
|
||||
await acquire()
|
||||
try {
|
||||
// Why: --cache 60s saves rate-limit budget during polling; explicit refresh skips it for fresh data.
|
||||
const cacheArgs = options?.noCache ? [] : ['--cache', '60s']
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'graphql',
|
||||
...cacheArgs,
|
||||
'-f',
|
||||
`owner=${ownerRepo.owner}`,
|
||||
'-f',
|
||||
`repo=${ownerRepo.repo}`,
|
||||
'-F',
|
||||
`pr=${prNumber}`,
|
||||
'-f',
|
||||
`query=${PR_CHECKS_ROLLUP_QUERY}`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
|
||||
const checks = mapGraphQLPRChecksResponse(
|
||||
ownerRepo,
|
||||
JSON.parse(stdout) as GraphQLPRChecksResponse
|
||||
)
|
||||
if (checks !== null) {
|
||||
return checks
|
||||
}
|
||||
} catch (err) {
|
||||
// Why: fall back to older `gh pr checks` when GitHub's richer rollup query is unavailable.
|
||||
console.warn('getPRChecks via GraphQL rollup failed, falling back to gh pr checks:', err)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
const restChecks = await getPRChecksViaRestFallback(
|
||||
ownerRepo,
|
||||
headSha,
|
||||
ghOptions,
|
||||
options?.noCache
|
||||
)
|
||||
if (restChecks !== null) {
|
||||
return restChecks
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await fallbackToPRChecks()
|
||||
} catch (err) {
|
||||
console.warn('getPRChecks failed:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
export const PR_CHECKS_ROLLUP_QUERY = `
|
||||
query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $pr) {
|
||||
headRefOid
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
statusCheckRollup {
|
||||
contexts(first: 100) {
|
||||
nodes {
|
||||
__typename
|
||||
... on CheckRun {
|
||||
databaseId
|
||||
name
|
||||
status
|
||||
conclusion
|
||||
detailsUrl
|
||||
url
|
||||
checkSuite {
|
||||
databaseId
|
||||
workflowRun {
|
||||
databaseId
|
||||
}
|
||||
}
|
||||
}
|
||||
... on StatusContext {
|
||||
context
|
||||
state
|
||||
targetUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
checkSuites(first: 100) {
|
||||
nodes {
|
||||
databaseId
|
||||
status
|
||||
conclusion
|
||||
url
|
||||
app {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export type GraphQLPRChecksResponse = {
|
||||
data?: {
|
||||
repository?: {
|
||||
pullRequest?: {
|
||||
headRefOid?: string | null
|
||||
commits?: {
|
||||
nodes?: { commit?: GraphQLPRChecksCommit | null }[] | null
|
||||
} | null
|
||||
} | null
|
||||
} | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export type GraphQLPRChecksCommit = {
|
||||
statusCheckRollup?: {
|
||||
contexts?: {
|
||||
nodes?: GraphQLStatusCheckContext[] | null
|
||||
} | null
|
||||
} | null
|
||||
checkSuites?: {
|
||||
nodes?: GraphQLCheckSuite[] | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export type GraphQLCheckRunContext = {
|
||||
__typename: 'CheckRun'
|
||||
databaseId?: number | null
|
||||
name?: string | null
|
||||
status?: string | null
|
||||
conclusion?: string | null
|
||||
detailsUrl?: string | null
|
||||
url?: string | null
|
||||
checkSuite?: {
|
||||
databaseId?: number | null
|
||||
workflowRun?: { databaseId?: number | null } | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export type GraphQLStatusContext = {
|
||||
__typename: 'StatusContext'
|
||||
context?: string | null
|
||||
state?: string | null
|
||||
targetUrl?: string | null
|
||||
}
|
||||
|
||||
export type GraphQLStatusCheckContext =
|
||||
| GraphQLCheckRunContext
|
||||
| GraphQLStatusContext
|
||||
| { __typename?: string | null }
|
||||
|
||||
export type GraphQLCheckSuite = {
|
||||
databaseId?: number | null
|
||||
status?: string | null
|
||||
conclusion?: string | null
|
||||
url?: string | null
|
||||
app?: { name?: string | null; slug?: string | null } | null
|
||||
}
|
||||
|
||||
export type RestCheckRun = {
|
||||
id?: number
|
||||
name: string
|
||||
status: string
|
||||
conclusion: string | null
|
||||
html_url: string
|
||||
details_url: string | null
|
||||
}
|
||||
|
||||
export type RestCommitStatus = {
|
||||
context?: string
|
||||
state?: string
|
||||
target_url?: string | null
|
||||
}
|
||||
|
||||
export type RestCheckSuite = {
|
||||
id?: number | null
|
||||
status: string | null
|
||||
conclusion: string | null
|
||||
app?: { name?: string | null; slug?: string | null } | null
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { PRCheckDetail } from '../../../../shared/github/check-types'
|
||||
import { githubRepositoryWebHost, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { mapCheckRunRESTStatus, mapCheckRunRESTConclusion, mapCommitStatusRESTStatus, mapCommitStatusRESTConclusion } from '../../mappers'
|
||||
import type { GraphQLPRChecksResponse, GraphQLCheckRunContext, GraphQLStatusContext, GraphQLStatusCheckContext, GraphQLCheckSuite, RestCheckRun, RestCommitStatus } from './pr-checks-graphql-query'
|
||||
import { nullableString, nullableNumber, parseActionsRunId } from './check-detail-field-mapping'
|
||||
export function isGraphQLCheckRunContext(
|
||||
context: GraphQLStatusCheckContext
|
||||
): context is GraphQLCheckRunContext {
|
||||
return context.__typename === 'CheckRun'
|
||||
}
|
||||
|
||||
export function isGraphQLStatusContext(
|
||||
context: GraphQLStatusCheckContext
|
||||
): context is GraphQLStatusContext {
|
||||
return context.__typename === 'StatusContext'
|
||||
}
|
||||
|
||||
export function mapGraphQLCheckRunContext(context: GraphQLCheckRunContext): PRCheckDetail | null {
|
||||
const name = nullableString(context.name)
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
const url = nullableString(context.detailsUrl) ?? nullableString(context.url)
|
||||
const checkRunId = nullableNumber(context.databaseId)
|
||||
const workflowRunId =
|
||||
nullableNumber(context.checkSuite?.workflowRun?.databaseId) ?? parseActionsRunId(url)
|
||||
return {
|
||||
name,
|
||||
status: mapCheckRunRESTStatus(context.status ?? ''),
|
||||
conclusion: mapCheckRunRESTConclusion(context.status ?? '', context.conclusion ?? null),
|
||||
url,
|
||||
...(checkRunId !== null ? { checkRunId } : {}),
|
||||
...(typeof workflowRunId === 'number' ? { workflowRunId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function mapGraphQLStatusContext(context: GraphQLStatusContext): PRCheckDetail | null {
|
||||
const name = nullableString(context.context)
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
const url = nullableString(context.targetUrl)
|
||||
const workflowRunId = parseActionsRunId(url)
|
||||
return {
|
||||
name,
|
||||
status: mapCommitStatusRESTStatus(context.state ?? ''),
|
||||
conclusion: mapCommitStatusRESTConclusion(context.state ?? ''),
|
||||
url,
|
||||
...(workflowRunId !== undefined ? { workflowRunId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function mapRestCheckRun(checkRun: RestCheckRun): PRCheckDetail {
|
||||
return {
|
||||
name: checkRun.name,
|
||||
status: mapCheckRunRESTStatus(checkRun.status),
|
||||
conclusion: mapCheckRunRESTConclusion(checkRun.status, checkRun.conclusion),
|
||||
url: checkRun.details_url || checkRun.html_url || null,
|
||||
...(typeof checkRun.id === 'number' ? { checkRunId: checkRun.id } : {}),
|
||||
workflowRunId: parseActionsRunId(checkRun.details_url || checkRun.html_url || null)
|
||||
}
|
||||
}
|
||||
|
||||
export function mapRestCommitStatus(status: RestCommitStatus): PRCheckDetail | null {
|
||||
const name = nullableString(status.context)
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
const url = nullableString(status.target_url)
|
||||
const workflowRunId = parseActionsRunId(url)
|
||||
return {
|
||||
name,
|
||||
status: mapCommitStatusRESTStatus(status.state ?? ''),
|
||||
conclusion: mapCommitStatusRESTConclusion(status.state ?? ''),
|
||||
url,
|
||||
...(workflowRunId !== undefined ? { workflowRunId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function mapGraphQLPendingApprovalCheckSuite(
|
||||
ownerRepo: GitHubApiRepository,
|
||||
suite: GraphQLCheckSuite,
|
||||
headSha: string | null | undefined,
|
||||
index: number
|
||||
): PRCheckDetail {
|
||||
return {
|
||||
name: getPendingApprovalCheckSuiteName(suite, headSha, index),
|
||||
status: 'completed',
|
||||
conclusion: 'action_required',
|
||||
// Why: suite-only approval blockers have no check run; link the suite page when GraphQL exposes one.
|
||||
url:
|
||||
nullableString(suite.url) ??
|
||||
(headSha ? getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.databaseId) : null)
|
||||
}
|
||||
}
|
||||
|
||||
export function mapGraphQLPRChecksResponse(
|
||||
ownerRepo: GitHubApiRepository,
|
||||
response: GraphQLPRChecksResponse
|
||||
): PRCheckDetail[] | null {
|
||||
const pullRequest = response.data?.repository?.pullRequest
|
||||
if (!pullRequest) {
|
||||
return null
|
||||
}
|
||||
const commit = pullRequest.commits?.nodes?.[0]?.commit
|
||||
if (!commit) {
|
||||
return []
|
||||
}
|
||||
|
||||
const contexts = commit.statusCheckRollup?.contexts?.nodes ?? []
|
||||
const checkRunContexts = contexts.filter(isGraphQLCheckRunContext)
|
||||
const checkRuns = checkRunContexts
|
||||
.map(mapGraphQLCheckRunContext)
|
||||
.filter((check): check is PRCheckDetail => check !== null)
|
||||
const checkRunNames = new Set(checkRuns.map((check) => check.name))
|
||||
const checkSuiteIdsWithRuns = new Set(
|
||||
checkRunContexts
|
||||
.map((context) => nullableNumber(context.checkSuite?.databaseId))
|
||||
.filter((id): id is number => id !== null)
|
||||
)
|
||||
// Why: mixed-CI repos expose Jenkins/Prow/Tide as legacy status contexts in the same rollup; keep check-run metadata on name collisions.
|
||||
const legacyStatuses = contexts
|
||||
.filter(isGraphQLStatusContext)
|
||||
.map(mapGraphQLStatusContext)
|
||||
.filter((check): check is PRCheckDetail => check !== null && !checkRunNames.has(check.name))
|
||||
const pendingApprovalChecks = (commit.checkSuites?.nodes ?? [])
|
||||
.filter((suite) => suite.conclusion?.toLowerCase() === 'action_required')
|
||||
.filter((suite) => {
|
||||
const suiteId = nullableNumber(suite.databaseId)
|
||||
return suiteId === null || !checkSuiteIdsWithRuns.has(suiteId)
|
||||
})
|
||||
.map((suite, index) =>
|
||||
mapGraphQLPendingApprovalCheckSuite(ownerRepo, suite, pullRequest.headRefOid, index)
|
||||
)
|
||||
|
||||
return [...checkRuns, ...legacyStatuses, ...pendingApprovalChecks]
|
||||
}
|
||||
|
||||
export function getPendingApprovalCheckSuiteName(
|
||||
suite: {
|
||||
id?: number | null
|
||||
databaseId?: number | null
|
||||
app?: { name?: string | null; slug?: string | null } | null
|
||||
},
|
||||
headSha: string | null | undefined,
|
||||
index: number
|
||||
): string {
|
||||
const appName = suite.app?.name ?? suite.app?.slug ?? null
|
||||
const rawSuiteId = suite.databaseId ?? suite.id
|
||||
const suiteId =
|
||||
typeof rawSuiteId === 'number' && Number.isFinite(rawSuiteId) ? `#${rawSuiteId}` : null
|
||||
if (appName && suiteId) {
|
||||
return `${appName} ${suiteId}`
|
||||
}
|
||||
if (appName) {
|
||||
return appName
|
||||
}
|
||||
if (suiteId) {
|
||||
return suiteId
|
||||
}
|
||||
return `${headSha?.slice(0, 12) ?? 'check-suite'}:${index + 1}`
|
||||
}
|
||||
|
||||
export function getPendingApprovalCheckSuiteUrl(
|
||||
ownerRepo: GitHubApiRepository,
|
||||
headSha: string,
|
||||
suiteId: number | null | undefined
|
||||
): string {
|
||||
const base = `https://${githubRepositoryWebHost(ownerRepo)}/${ownerRepo.owner}/${ownerRepo.repo}/commits/${headSha}/checks`
|
||||
return typeof suiteId === 'number' && Number.isFinite(suiteId)
|
||||
? `${base}#check-suite-${suiteId}`
|
||||
: base
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { GitHubRerunPRChecksResult } from '../../../../shared/github/check-types'
|
||||
import { ghExecFileAsync, acquire, release, classifyGhError, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { getPRChecks } from './get-pr-checks'
|
||||
import { parseActionsRunId } from './check-detail-field-mapping'
|
||||
export async function rerunPRChecks(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
options: {
|
||||
headSha?: string
|
||||
failedOnly?: boolean
|
||||
prRepo?: GitHubApiRepository | null
|
||||
} = {},
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitHubRerunPRChecksResult> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
options.prRepo,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
|
||||
}
|
||||
|
||||
const checks = await getPRChecks(
|
||||
repoPath,
|
||||
prNumber,
|
||||
options.headSha,
|
||||
ownerRepo,
|
||||
{ noCache: true },
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
const candidates = options.failedOnly
|
||||
? checks.filter((check) =>
|
||||
['failure', 'cancelled', 'timed_out'].includes(check.conclusion ?? '')
|
||||
)
|
||||
: checks
|
||||
const workflowRunIds = new Set(
|
||||
candidates
|
||||
.map((check) => check.workflowRunId ?? parseActionsRunId(check.url))
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
)
|
||||
const checkRunIds = new Set(
|
||||
candidates
|
||||
.filter((check) => !check.workflowRunId && !parseActionsRunId(check.url))
|
||||
.map((check) => check.checkRunId)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
)
|
||||
|
||||
if (workflowRunIds.size === 0 && checkRunIds.size === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: options.failedOnly
|
||||
? 'No failed GitHub Actions checks to rerun.'
|
||||
: 'No rerunnable checks found.'
|
||||
}
|
||||
}
|
||||
|
||||
let count = 0
|
||||
await acquire()
|
||||
try {
|
||||
for (const runId of workflowRunIds) {
|
||||
const endpoint = options.failedOnly
|
||||
? `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${runId}/rerun-failed-jobs`
|
||||
: `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${runId}/rerun`
|
||||
await ghExecFileAsync(['api', '-X', 'POST', endpoint], {
|
||||
...ghOptions,
|
||||
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
|
||||
})
|
||||
count += 1
|
||||
}
|
||||
for (const checkRunId of checkRunIds) {
|
||||
await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'-X',
|
||||
'POST',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${checkRunId}/rerequest`
|
||||
],
|
||||
{ ...ghOptions, env: { ...process.env, GH_PROMPT_DISABLED: '1' } }
|
||||
)
|
||||
count += 1
|
||||
}
|
||||
return { ok: true, count }
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
|
||||
return { ok: false, error: classifyGhError(message).message }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { GitHubCommentResult, GitHubPRReviewCommentInput } from '../../../../shared/github/comment-types'
|
||||
import { ghExecFileAsync, acquire, release, classifyGhError, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { mapReviewCommentResponse } from './../map/review-comment-response'
|
||||
export async function addPRReviewComment(
|
||||
args: GitHubPRReviewCommentInput & {
|
||||
connectionId?: string | null
|
||||
localGitOptions?: LocalGitExecOptions
|
||||
}
|
||||
): Promise<GitHubCommentResult> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
args.repoPath,
|
||||
args.prRepo,
|
||||
args.connectionId,
|
||||
args.localGitOptions
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
const fields = [
|
||||
'api',
|
||||
'-X',
|
||||
'POST',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${args.prNumber}/comments`,
|
||||
'--raw-field',
|
||||
`body=${args.body}`,
|
||||
'--raw-field',
|
||||
`commit_id=${args.commitId}`,
|
||||
'--raw-field',
|
||||
`path=${args.path}`,
|
||||
'--field',
|
||||
`line=${String(args.line)}`,
|
||||
'--raw-field',
|
||||
'side=RIGHT'
|
||||
]
|
||||
if (typeof args.startLine === 'number' && args.startLine !== args.line) {
|
||||
fields.push(
|
||||
'--field',
|
||||
`start_line=${String(args.startLine)}`,
|
||||
'--raw-field',
|
||||
'start_side=RIGHT'
|
||||
)
|
||||
}
|
||||
const { stdout } = await ghExecFileAsync(fields, ghOptions)
|
||||
return {
|
||||
ok: true,
|
||||
comment: mapReviewCommentResponse(
|
||||
JSON.parse(stdout),
|
||||
args.body,
|
||||
args.path,
|
||||
args.line,
|
||||
args.startLine
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
return { ok: false, error: classifyGhError(stderr).message }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function addPRReviewCommentReply(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
commentId: number,
|
||||
body: string,
|
||||
threadId?: string,
|
||||
path?: string,
|
||||
line?: number,
|
||||
connectionId?: string | null,
|
||||
prRepo?: GitHubApiRepository | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitHubCommentResult> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
prRepo,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'-X',
|
||||
'POST',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/comments/${commentId}/replies`,
|
||||
'--raw-field',
|
||||
`body=${body}`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
const data = JSON.parse(stdout) as Parameters<typeof mapReviewCommentResponse>[0]
|
||||
if (typeof data.id !== 'number' || !Number.isSafeInteger(data.id) || data.id < 1) {
|
||||
return { ok: false, error: 'Unexpected response from GitHub' }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
comment: mapReviewCommentResponse(data, body, path, line, undefined, threadId)
|
||||
}
|
||||
} catch (err) {
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
return { ok: false, error: classifyGhError(stderr).message }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../../../shared/hosted-review'
|
||||
import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { ghExecFileAsync, acquire, release, ghRepoExecOptions, githubRepoContext } from '../../gh-utils'
|
||||
import { getHostedReviewLocalGitOptions, type HostedReviewExecutionOptions } from '../../../source-control/hosted-review-git-options'
|
||||
import { getOriginGitHubApiRepository, githubHostExecOptions } from '../../github-api-repository'
|
||||
import { classifyCreatePRError, parseCreatePRPayload } from './create-pr-error-classification'
|
||||
import { findOpenPRByHeadBase, readPullRequestTemplate } from './pull-request-template'
|
||||
export async function createGitHubPullRequest(
|
||||
repoPath: string,
|
||||
input: CreateHostedReviewInput,
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
): Promise<CreateHostedReviewResult> {
|
||||
if (input.provider !== 'github') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unsupported_provider',
|
||||
error: 'Creating reviews for this provider is not supported yet.'
|
||||
}
|
||||
}
|
||||
|
||||
// Why: creation targets the origin owning the unqualified head branch; the shared resolver preserves its host (#7331, #8312).
|
||||
const ownerRepo = await getOriginGitHubApiRepository(
|
||||
repoPath,
|
||||
connectionId,
|
||||
getHostedReviewLocalGitOptions(options)
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unsupported_provider',
|
||||
error: 'Creating pull requests requires a GitHub remote.'
|
||||
}
|
||||
}
|
||||
// The runner host-qualifies --repo from options.host for GHES (#8312).
|
||||
const repoArg = `${ownerRepo.owner}/${ownerRepo.repo}`
|
||||
|
||||
const base = normalizeHostedReviewBaseRef(input.base)
|
||||
const head = input.head ? normalizeHostedReviewHeadRef(input.head) || undefined : undefined
|
||||
const title = input.title.trim()
|
||||
if (!base || !title) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'validation',
|
||||
error: 'Create PR failed: base branch and title are required.'
|
||||
}
|
||||
}
|
||||
if (head && head.toLowerCase() === base.toLowerCase()) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'validation',
|
||||
error: 'Create PR failed: choose a different base branch before creating a pull request.'
|
||||
}
|
||||
}
|
||||
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'orca-pr-body-'))
|
||||
await acquire()
|
||||
const bodyPath = join(tempDir, 'body.md')
|
||||
try {
|
||||
const body =
|
||||
input.useTemplate && !input.body?.trim()
|
||||
? await readPullRequestTemplate(repoPath, connectionId)
|
||||
: (input.body ?? '')
|
||||
await writeFile(bodyPath, body, 'utf8')
|
||||
const createArgs = [
|
||||
'pr',
|
||||
'create',
|
||||
'--repo',
|
||||
repoArg,
|
||||
'--base',
|
||||
base,
|
||||
'--title',
|
||||
title,
|
||||
'--body-file',
|
||||
bodyPath
|
||||
]
|
||||
if (head) {
|
||||
createArgs.push('--head', head)
|
||||
}
|
||||
if (input.draft) {
|
||||
createArgs.push('--draft')
|
||||
}
|
||||
try {
|
||||
const context = githubRepoContext(repoPath, connectionId)
|
||||
const { stdout } = await ghExecFileAsync(createArgs, {
|
||||
...ghRepoExecOptions(context),
|
||||
...(connectionId ? {} : getHostedReviewLocalGitOptions(options)),
|
||||
...githubHostExecOptions(ownerRepo),
|
||||
timeout: 60_000,
|
||||
idempotent: false
|
||||
})
|
||||
const created = parseCreatePRPayload(stdout)
|
||||
if (created) {
|
||||
return { ok: true, ...created }
|
||||
}
|
||||
const found = head
|
||||
? await findOpenPRByHeadBase({
|
||||
repoPath,
|
||||
repo: ownerRepo,
|
||||
head,
|
||||
base,
|
||||
connectionId,
|
||||
options
|
||||
}).catch(() => null)
|
||||
: null
|
||||
if (found) {
|
||||
return { ok: true, ...found }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unknown_completion',
|
||||
error: 'PR creation may have completed. Refreshing branch review state...'
|
||||
}
|
||||
} catch (error) {
|
||||
const classified = classifyCreatePRError(error)
|
||||
if (
|
||||
!classified.ok &&
|
||||
(classified.code === 'already_exists' || classified.code === 'unknown_completion') &&
|
||||
head
|
||||
) {
|
||||
const existing = await findOpenPRByHeadBase({
|
||||
repoPath,
|
||||
repo: ownerRepo,
|
||||
head,
|
||||
base,
|
||||
connectionId,
|
||||
options
|
||||
}).catch(() => null)
|
||||
if (existing) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'already_exists',
|
||||
error: 'A pull request already exists for this branch.',
|
||||
existingReview: existing
|
||||
}
|
||||
}
|
||||
}
|
||||
return classified
|
||||
}
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { CreateHostedReviewResult } from '../../../../shared/hosted-review'
|
||||
import { extractExecError } from '../../../git/exec-error'
|
||||
export function classifyCreatePRError(error: unknown): CreateHostedReviewResult {
|
||||
const { stderr, stdout } = extractExecError(error)
|
||||
const message = `${stderr}\n${stdout}`.trim()
|
||||
if (message) {
|
||||
console.warn('createGitHubPullRequest failed:', message)
|
||||
}
|
||||
const lower = message.toLowerCase()
|
||||
if (
|
||||
lower.includes('not logged') ||
|
||||
lower.includes('not authenticated') ||
|
||||
lower.includes('authentication') ||
|
||||
lower.includes('gh auth login') ||
|
||||
lower.includes('http 401')
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'auth_required',
|
||||
error:
|
||||
'Create PR failed: GitHub is not authenticated. Next step: run gh auth login in this environment.'
|
||||
}
|
||||
}
|
||||
if (lower.includes('already exists') || lower.includes('a pull request already exists')) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'already_exists',
|
||||
error: 'A pull request already exists for this branch.'
|
||||
}
|
||||
}
|
||||
if (lower.includes('timed out') || lower.includes('timeout')) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unknown_completion',
|
||||
error: 'PR creation may have completed. Refreshing branch review state...'
|
||||
}
|
||||
}
|
||||
if (lower.includes('validation failed') || lower.includes('http 422')) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'validation',
|
||||
error:
|
||||
'Create PR failed: GitHub rejected the pull request. Check the base branch and branch state, then try again.'
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unknown',
|
||||
error: 'Create PR failed: GitHub could not create the pull request. Try again in a moment.'
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCreatePRPayload(stdout: string): { number: number; url: string } | null {
|
||||
const trimmed = stdout.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as { number?: unknown; url?: unknown }
|
||||
const number = Number(parsed.number)
|
||||
const url = typeof parsed.url === 'string' ? parsed.url.trim() : ''
|
||||
if (Number.isInteger(number) && number > 0 && url) {
|
||||
return { number, url }
|
||||
}
|
||||
} catch {
|
||||
// Fall through to URL parsing for older gh versions without --json support.
|
||||
}
|
||||
// Why: match any host (not just github.com) so a GHES PR URL still parses (#8312).
|
||||
const urlMatch = trimmed.match(/https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/(\d+)/)
|
||||
if (!urlMatch) {
|
||||
return null
|
||||
}
|
||||
return { number: Number(urlMatch[1]), url: urlMatch[0] }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch'
|
||||
import { joinWorktreeRelativePath } from '../../../runtime/runtime-relative-paths'
|
||||
import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from '../../gh-utils'
|
||||
import { getHostedReviewLocalGitOptions, type HostedReviewExecutionOptions } from '../../../source-control/hosted-review-git-options'
|
||||
import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository'
|
||||
export async function findOpenPRByHeadBase(args: {
|
||||
repoPath: string
|
||||
repo: GitHubApiRepository
|
||||
head: string
|
||||
base: string
|
||||
connectionId?: string | null
|
||||
options?: HostedReviewExecutionOptions
|
||||
}): Promise<{ number: number; url: string } | null> {
|
||||
const context = githubRepoContext(args.repoPath, args.connectionId)
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--repo',
|
||||
`${args.repo.owner}/${args.repo.repo}`,
|
||||
'--head',
|
||||
args.head,
|
||||
'--base',
|
||||
args.base,
|
||||
'--state',
|
||||
'open',
|
||||
'--limit',
|
||||
'2',
|
||||
'--json',
|
||||
'number,url'
|
||||
],
|
||||
{
|
||||
...ghRepoExecOptions(context),
|
||||
...(args.connectionId ? {} : getHostedReviewLocalGitOptions(args.options)),
|
||||
...githubHostExecOptions(args.repo)
|
||||
}
|
||||
)
|
||||
const list = JSON.parse(stdout) as { number?: number; url?: string }[]
|
||||
if (list.length !== 1 || !list[0]?.number || !list[0]?.url) {
|
||||
return null
|
||||
}
|
||||
return { number: list[0].number, url: list[0].url }
|
||||
}
|
||||
|
||||
export async function readPullRequestTemplate(
|
||||
repoPath: string,
|
||||
connectionId?: string | null
|
||||
): Promise<string> {
|
||||
const relativeCandidates = [
|
||||
'.github/pull_request_template.md',
|
||||
'.github/PULL_REQUEST_TEMPLATE.md',
|
||||
'pull_request_template.md',
|
||||
'PULL_REQUEST_TEMPLATE.md',
|
||||
'docs/pull_request_template.md',
|
||||
'docs/PULL_REQUEST_TEMPLATE.md'
|
||||
]
|
||||
const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined
|
||||
if (connectionId && !remoteProvider) {
|
||||
return ''
|
||||
}
|
||||
for (const relativeCandidate of relativeCandidates) {
|
||||
try {
|
||||
if (remoteProvider) {
|
||||
const result = await remoteProvider.readFile(
|
||||
joinWorktreeRelativePath(repoPath, relativeCandidate)
|
||||
)
|
||||
if (result.isBinary) {
|
||||
continue
|
||||
}
|
||||
return result.content
|
||||
}
|
||||
return await readFile(join(repoPath, relativeCandidate), 'utf8')
|
||||
} catch {
|
||||
// Try the next conventional PR template path.
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { type OwnerRepo } from '../../gh-utils'
|
||||
import type { GhExecOptions } from './../github-exec-scope'
|
||||
import { detectRepositoryMergeMetadata } from './repository-merge-metadata'
|
||||
import type { MainWorkItem } from './../map/work-item-field-coercion'
|
||||
export async function hydrateWorkItemRepositoryMergeMetadata(
|
||||
items: MainWorkItem[],
|
||||
ownerRepo: OwnerRepo | null,
|
||||
ghOptions: GhExecOptions
|
||||
): Promise<MainWorkItem[]> {
|
||||
const hasPullRequest = items.some((item) => item.type === 'pr')
|
||||
if (!ownerRepo || !hasPullRequest) {
|
||||
return items
|
||||
}
|
||||
// Why: merge settings are repo-level, so one cached probe keeps Tasks rows accurate without per-PR GraphQL fan-out.
|
||||
const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, undefined, ghOptions)
|
||||
if (!mergeMetadata.mergeMethodSettings && mergeMetadata.autoMergeAllowed === null) {
|
||||
return items
|
||||
}
|
||||
return items.map((item) =>
|
||||
item.type === 'pr'
|
||||
? {
|
||||
...item,
|
||||
...(mergeMetadata.autoMergeAllowed !== null
|
||||
? { autoMergeAllowed: mergeMetadata.autoMergeAllowed }
|
||||
: {}),
|
||||
...(mergeMetadata.mergeMethodSettings
|
||||
? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
|
||||
: {})
|
||||
}
|
||||
: item
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { GitHubPRMergeMethodSettings } from '../../../../shared/github/pull-request-types'
|
||||
export const MERGE_QUEUE_CACHE_TTL_MS = 10 * 60 * 1000
|
||||
|
||||
export const MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS = 60 * 1000
|
||||
|
||||
export const MERGE_QUEUE_CACHE_MAX_ENTRIES = 256
|
||||
|
||||
export type GitHubRepositoryMergeMetadata = {
|
||||
mergeQueueRequired: boolean | null
|
||||
autoMergeAllowed: boolean | null
|
||||
mergeMethodSettings?: GitHubPRMergeMethodSettings
|
||||
}
|
||||
|
||||
export const repositoryMergeMetadataCache = new Map<
|
||||
string,
|
||||
{ value: GitHubRepositoryMergeMetadata; expiresAt: number }
|
||||
>()
|
||||
|
||||
export function pruneRepositoryMergeMetadataCache(now = Date.now()): void {
|
||||
for (const [cacheKey, cached] of repositoryMergeMetadataCache) {
|
||||
if (cached.expiresAt <= now) {
|
||||
repositoryMergeMetadataCache.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
while (repositoryMergeMetadataCache.size > MERGE_QUEUE_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = repositoryMergeMetadataCache.keys().next().value
|
||||
if (oldestKey === undefined) {
|
||||
break
|
||||
}
|
||||
repositoryMergeMetadataCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function cacheRepositoryMergeMetadata(
|
||||
cacheKey: string,
|
||||
value: GitHubRepositoryMergeMetadata,
|
||||
ttlMs: number
|
||||
): void {
|
||||
const now = Date.now()
|
||||
pruneRepositoryMergeMetadataCache(now)
|
||||
// Why: merge metadata is keyed by user-controlled branch names; keep the cache bounded across many short-lived branches.
|
||||
repositoryMergeMetadataCache.delete(cacheKey)
|
||||
repositoryMergeMetadataCache.set(cacheKey, {
|
||||
value,
|
||||
expiresAt: now + ttlMs
|
||||
})
|
||||
pruneRepositoryMergeMetadataCache(now)
|
||||
}
|
||||
|
||||
export function _resetMergeQueueCacheForTests(): void {
|
||||
repositoryMergeMetadataCache.clear()
|
||||
}
|
||||
|
||||
export function _getMergeQueueCacheSizeForTests(): number {
|
||||
return repositoryMergeMetadataCache.size
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { normalizeGitHubPRMergeMethodSettings } from '../../../../shared/github/pull-request-merge-methods'
|
||||
import { ghExecFileAsync } from '../../gh-utils'
|
||||
import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key'
|
||||
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit'
|
||||
import type { GhExecOptions } from './../github-exec-scope'
|
||||
import { MERGE_QUEUE_CACHE_TTL_MS, MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS, repositoryMergeMetadataCache, pruneRepositoryMergeMetadataCache, cacheRepositoryMergeMetadata, type GitHubRepositoryMergeMetadata } from './repository-merge-metadata-cache'
|
||||
export async function detectRepositoryMergeMetadata(
|
||||
ownerRepo: GitHubApiRepository,
|
||||
branchName: string | undefined,
|
||||
ghOptions: GhExecOptions,
|
||||
executionScope = 'default'
|
||||
): Promise<GitHubRepositoryMergeMetadata> {
|
||||
const cacheKey = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}`
|
||||
pruneRepositoryMergeMetadataCache()
|
||||
const cached = repositoryMergeMetadataCache.get(cacheKey)
|
||||
if (cached) {
|
||||
return cached.value
|
||||
}
|
||||
const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
|
||||
if (guard.blocked) {
|
||||
return { mergeQueueRequired: null, autoMergeAllowed: null }
|
||||
}
|
||||
const query = branchName
|
||||
? `query($owner: String!, $repo: String!, $branch: String!, $qualified: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
viewerDefaultMergeMethod
|
||||
mergeCommitAllowed
|
||||
rebaseMergeAllowed
|
||||
squashMergeAllowed
|
||||
autoMergeAllowed
|
||||
mergeQueue(branch: $branch) { id }
|
||||
ref(qualifiedName: $qualified) {
|
||||
rules(first: 50) { nodes { type } }
|
||||
}
|
||||
}
|
||||
}`
|
||||
: `query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
viewerDefaultMergeMethod
|
||||
mergeCommitAllowed
|
||||
rebaseMergeAllowed
|
||||
squashMergeAllowed
|
||||
autoMergeAllowed
|
||||
}
|
||||
}`
|
||||
try {
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
|
||||
const args = [
|
||||
'api',
|
||||
'graphql',
|
||||
'-f',
|
||||
`query=${query}`,
|
||||
'-f',
|
||||
`owner=${ownerRepo.owner}`,
|
||||
'-f',
|
||||
`repo=${ownerRepo.repo}`
|
||||
]
|
||||
if (branchName) {
|
||||
args.push('-f', `branch=${branchName}`)
|
||||
args.push('-f', `qualified=refs/heads/${branchName}`)
|
||||
}
|
||||
const { stdout } = await ghExecFileAsync(args, {
|
||||
...ghOptions,
|
||||
...githubHostExecOptions(ownerRepo)
|
||||
})
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
data?: {
|
||||
repository?: {
|
||||
viewerDefaultMergeMethod?: unknown
|
||||
mergeCommitAllowed?: unknown
|
||||
rebaseMergeAllowed?: unknown
|
||||
squashMergeAllowed?: unknown
|
||||
autoMergeAllowed?: unknown
|
||||
mergeQueue?: { id?: unknown } | null
|
||||
ref?: { rules?: { nodes?: ({ type?: unknown } | null)[] | null } | null } | null
|
||||
} | null
|
||||
}
|
||||
}
|
||||
const repository = parsed.data?.repository
|
||||
const mergeMethodSettings = repository
|
||||
? normalizeGitHubPRMergeMethodSettings({
|
||||
defaultMethod: repository.viewerDefaultMergeMethod,
|
||||
mergeCommitAllowed: repository.mergeCommitAllowed,
|
||||
rebaseMergeAllowed: repository.rebaseMergeAllowed,
|
||||
squashMergeAllowed: repository.squashMergeAllowed
|
||||
})
|
||||
: undefined
|
||||
const value: GitHubRepositoryMergeMetadata = {
|
||||
mergeQueueRequired: branchName
|
||||
? Boolean(repository?.mergeQueue) ||
|
||||
Boolean(repository?.ref?.rules?.nodes?.some((rule) => rule?.type === 'MERGE_QUEUE'))
|
||||
: null,
|
||||
autoMergeAllowed:
|
||||
typeof repository?.autoMergeAllowed === 'boolean' ? repository.autoMergeAllowed : null,
|
||||
...(mergeMethodSettings ? { mergeMethodSettings } : {})
|
||||
}
|
||||
cacheRepositoryMergeMetadata(cacheKey, value, MERGE_QUEUE_CACHE_TTL_MS)
|
||||
return value
|
||||
} catch {
|
||||
// Why: cache a conservative result for failed merge-queue probes so we don't retry GraphQL on every poll while GitHub/network is unhappy.
|
||||
const value: GitHubRepositoryMergeMetadata = {
|
||||
mergeQueueRequired: null,
|
||||
autoMergeAllowed: null
|
||||
}
|
||||
cacheRepositoryMergeMetadata(cacheKey, value, MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS)
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -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,271 @@
|
||||
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[] }
|
||||
comments?: { nodes: GQLIssueComment[] }
|
||||
reviews?: { nodes: GQLIssueComment[] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const pullRequest = threadsData.data.repository.pullRequest
|
||||
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
|
||||
}
|
||||
graphQLReviewSummaries = (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)
|
||||
})
|
||||
)
|
||||
|
||||
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,135 @@
|
||||
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,75 @@
|
||||
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,188 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PRRefreshOutcome } from '../../../shared/github/pull-request-refresh-types'
|
||||
import { classifyPRRefreshError, safePRRefreshErrorMessage } from '../pr-refresh-error-classification'
|
||||
import { classifyGhError } from '../gh-utils'
|
||||
// Why: import from the lightweight module (not ./gh-utils) so tests mocking gh-utils still get the real functions.
|
||||
import { extractExecError, parseRetryAfterMs } from '../../git/exec-error'
|
||||
export function isNoPullRequestError(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return /no pull requests? found|could not find.*pull request/i.test(message)
|
||||
}
|
||||
|
||||
export function isNotFoundGhError(err: unknown): boolean {
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
return classifyGhError(stderr).type === 'not_found'
|
||||
}
|
||||
|
||||
export function shouldStopAfterExactLookupError(err: unknown): boolean {
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
const type = classifyGhError(stderr).type
|
||||
return type !== 'not_found'
|
||||
}
|
||||
|
||||
export function prRefreshUpstreamError(
|
||||
err: unknown
|
||||
): Extract<PRRefreshOutcome, { kind: 'upstream-error' }> {
|
||||
const errorType = classifyPRRefreshError(err)
|
||||
const outcome: Extract<PRRefreshOutcome, { kind: 'upstream-error' }> = {
|
||||
kind: 'upstream-error',
|
||||
errorType,
|
||||
message: safePRRefreshErrorMessage(errorType),
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
// Why: a Retry-After is a real cooldown — surface it as the retry schedule so the renderer doesn't retry into another 429.
|
||||
if (errorType === 'rate_limited') {
|
||||
const retryAfterMs = parseRetryAfterMs(extractExecError(err).stderr)
|
||||
if (retryAfterMs !== null && retryAfterMs > 0) {
|
||||
const retryAt = Date.now() + retryAfterMs
|
||||
outcome.nextAutoRetryAt = retryAt
|
||||
outcome.retryDisabledUntil = retryAt
|
||||
}
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { type LocalGitExecOptions, type OwnerRepo } from '../gh-utils'
|
||||
import { hasHostedReviewLocalGitOptions, getHostedReviewLocalGitOptions, type HostedReviewExecutionOptions } from '../../source-control/hosted-review-git-options'
|
||||
import { type GitHubRepoExecOptions } from '../github-api-repository'
|
||||
import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key'
|
||||
export type GhExecOptions = GitHubRepoExecOptions & { signal?: AbortSignal }
|
||||
|
||||
export type HostedReviewLocalGitOptions = ReturnType<typeof getHostedReviewLocalGitOptions>
|
||||
|
||||
export function hostedReviewLocalGitOptionArgs(
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
): [] | [HostedReviewLocalGitOptions] {
|
||||
return hasHostedReviewLocalGitOptions(options) ? [getHostedReviewLocalGitOptions(options)] : []
|
||||
}
|
||||
|
||||
export function githubPRStackExecutionScope(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): string {
|
||||
return connectionId ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}`
|
||||
}
|
||||
|
||||
export function sameOwnerRepo(left: OwnerRepo | null, right: OwnerRepo | null): boolean {
|
||||
// Why: casing does not distinguish GitHub repos, but the same slug on different hosts does.
|
||||
return Boolean(left && right && githubRepoIdentityKey(left) === githubRepoIdentityKey(right))
|
||||
}
|
||||
|
||||
// Why: exact-linked fallback has no dataRepo; derive its host-aware identity from the web URL for merged-PR membership checks.
|
||||
export function ownerRepoFromPullRequestUrl(url: string): OwnerRepo | null {
|
||||
const match = url.match(/^https?:\/\/([^/\s]+)\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/)
|
||||
return match ? { owner: match[2], repo: match[3], host: match[1] } : null
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { IssueSourcePreference } from '../../../../shared/repo-types'
|
||||
import { isGitHubWorkItemsQueryTooLarge } from '../../../../shared/github/work-items-query-bounds'
|
||||
import { parseTaskQuery, type ParsedTaskQuery } from '../../../../shared/task-query'
|
||||
import { ghExecFileAsync, acquire, release, ghRepoExecOptions, githubRepoContext, type LocalGitExecOptions, type OwnerRepo } from '../../gh-utils'
|
||||
import { githubHostExecOptions, resolveIssueGitHubApiRepositorySource } from '../../github-api-repository'
|
||||
import { getRateLimit, noteRepositoryRateLimitSpend, repositoryRateLimitGuard, spendsSharedGitHubComQuota } from '../../rate-limit'
|
||||
import { sameOwnerRepo } from './../github-exec-scope'
|
||||
import { resolvePrWorkItemSource } from './work-item-list-request'
|
||||
import { buildSearchQueryString, defaultOpenWorkItemQuery } from './work-item-search-query'
|
||||
export async function countWorkItemsForQuery(
|
||||
repoPath: string,
|
||||
ownerRepo: OwnerRepo,
|
||||
query: ParsedTaskQuery,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<number> {
|
||||
const searchQ = buildSearchQueryString(ownerRepo, query)
|
||||
const ghOptions = {
|
||||
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
|
||||
...githubHostExecOptions(ownerRepo)
|
||||
}
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'--cache',
|
||||
'120s',
|
||||
`search/issues?q=${encodeURIComponent(searchQ)}&per_page=1`,
|
||||
'--jq',
|
||||
'.total_count'
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
// Why: over-counting cache hits is the safe direction — the next probe corrects the estimate.
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'search', 1, ghOptions)
|
||||
return Number.parseInt(stdout.trim(), 10) || 0
|
||||
}
|
||||
|
||||
// Why: cached 120s to avoid burning the 30/min search rate limit that backs the pagination total.
|
||||
export async function countWorkItems(
|
||||
repoPath: string,
|
||||
query?: string,
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<number> {
|
||||
const trimmedQuery = query?.trim() ?? ''
|
||||
if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) {
|
||||
return 0
|
||||
}
|
||||
const [issueResolved, prResolved] = await Promise.all([
|
||||
resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions),
|
||||
resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions)
|
||||
])
|
||||
const issueOwnerRepo = issueResolved.source
|
||||
const prOwnerRepo = prResolved.source
|
||||
const ownerRepo = prOwnerRepo ?? issueOwnerRepo
|
||||
if (!ownerRepo) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const parsedQuery = trimmedQuery ? parseTaskQuery(trimmedQuery) : null
|
||||
const effectiveQuery = parsedQuery ?? defaultOpenWorkItemQuery()
|
||||
const ghOptions = {
|
||||
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
|
||||
...githubHostExecOptions(ownerRepo)
|
||||
}
|
||||
|
||||
// Why: counts are decorative, so stop when the 30/min search budget is gone rather than spawn into 403s (getRateLimit is 30s-cached).
|
||||
if (spendsSharedGitHubComQuota(ownerRepo, ghOptions)) {
|
||||
await getRateLimit()
|
||||
}
|
||||
if (repositoryRateLimitGuard(ownerRepo, 'search', ghOptions).blocked) {
|
||||
return 0
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
if (sameOwnerRepo(issueOwnerRepo, prOwnerRepo)) {
|
||||
return await countWorkItemsForQuery(
|
||||
repoPath,
|
||||
ownerRepo,
|
||||
effectiveQuery,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
}
|
||||
|
||||
const counts: Promise<number>[] = []
|
||||
// Why: draft/reviewRequested/reviewedBy are PR-only, so the issue half would always return 0 — skip it to save a search call.
|
||||
const hasPrOnlyFilter =
|
||||
effectiveQuery.draft ||
|
||||
effectiveQuery.reviewRequested !== null ||
|
||||
effectiveQuery.reviewedBy !== null
|
||||
if (
|
||||
effectiveQuery.scope !== 'pr' &&
|
||||
effectiveQuery.state !== 'merged' &&
|
||||
!hasPrOnlyFilter &&
|
||||
issueOwnerRepo
|
||||
) {
|
||||
counts.push(
|
||||
countWorkItemsForQuery(
|
||||
repoPath,
|
||||
issueOwnerRepo,
|
||||
{ ...effectiveQuery, scope: 'issue' },
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
)
|
||||
}
|
||||
if (effectiveQuery.scope !== 'issue' && prOwnerRepo) {
|
||||
counts.push(
|
||||
countWorkItemsForQuery(
|
||||
repoPath,
|
||||
prOwnerRepo,
|
||||
{ ...effectiveQuery, scope: 'pr' },
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
)
|
||||
}
|
||||
// Why: allSettled so one failing search side doesn't zero the total; sum only fulfilled halves.
|
||||
const results = await Promise.allSettled(counts)
|
||||
let total = 0
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
total += r.value
|
||||
} else {
|
||||
console.warn('countWorkItems partial failure:', r.reason)
|
||||
}
|
||||
}
|
||||
return total
|
||||
} catch (err) {
|
||||
console.warn('countWorkItems failed:', err)
|
||||
return 0
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ListWorkItemsResult } from '../../../../shared/github/work-item-types'
|
||||
import type { IssueSourcePreference } from '../../../../shared/repo-types'
|
||||
import { isGitHubWorkItemsQueryTooLarge } from '../../../../shared/github/work-items-query-bounds'
|
||||
import { parseTaskQuery } from '../../../../shared/task-query'
|
||||
import { acquire, release, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { resolveIssueGitHubApiRepositorySource } from '../../github-api-repository'
|
||||
import type { MainWorkItem } from './../map/work-item-field-coercion'
|
||||
import { normalizeWorkItemPage, resolvePrWorkItemSource } from './work-item-list-request'
|
||||
import { listRecentWorkItems, listQueriedWorkItems } from './work-item-pages'
|
||||
export async function listWorkItems(
|
||||
repoPath: string,
|
||||
limit = 24,
|
||||
query?: string,
|
||||
page?: number,
|
||||
preference?: IssueSourcePreference,
|
||||
connectionId?: string | null,
|
||||
noCache?: boolean,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<ListWorkItemsResult<MainWorkItem>> {
|
||||
const trimmedQuery = query?.trim() ?? ''
|
||||
const requestedPage = normalizeWorkItemPage(page)
|
||||
if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) {
|
||||
return {
|
||||
items: [],
|
||||
sources: {
|
||||
issues: null,
|
||||
prs: null,
|
||||
originCandidate: null,
|
||||
upstreamCandidate: null
|
||||
}
|
||||
}
|
||||
}
|
||||
const [issueResolved, prResolved] = await Promise.all([
|
||||
resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions),
|
||||
resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions)
|
||||
])
|
||||
const issueOwnerRepo = issueResolved.source
|
||||
const prOwnerRepo = prResolved.source
|
||||
await acquire()
|
||||
try {
|
||||
// Why: let errors propagate to IPC — a catch-all would make failure indistinguishable from empty and under-report per-repo failures.
|
||||
const partial = !trimmedQuery
|
||||
? await listRecentWorkItems(
|
||||
repoPath,
|
||||
issueOwnerRepo,
|
||||
prOwnerRepo,
|
||||
limit,
|
||||
requestedPage,
|
||||
connectionId,
|
||||
noCache,
|
||||
localGitOptions
|
||||
)
|
||||
: await listQueriedWorkItems(
|
||||
repoPath,
|
||||
issueOwnerRepo,
|
||||
prOwnerRepo,
|
||||
parseTaskQuery(trimmedQuery),
|
||||
limit,
|
||||
requestedPage,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
|
||||
const errors =
|
||||
partial.issuesError || partial.prsError
|
||||
? {
|
||||
...(partial.issuesError ? { issues: partial.issuesError } : {}),
|
||||
...(partial.prsError ? { prs: partial.prsError } : {})
|
||||
}
|
||||
: undefined
|
||||
return {
|
||||
items: partial.items,
|
||||
sources: {
|
||||
issues: issueOwnerRepo,
|
||||
prs: prOwnerRepo,
|
||||
originCandidate: prResolved.originCandidate,
|
||||
upstreamCandidate: prResolved.upstreamCandidate
|
||||
},
|
||||
...(errors ? { errors } : {}),
|
||||
...(issueResolved.fellBack ? { issueSourceFellBack: true } : {})
|
||||
}
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { ClassifiedError } from '../../../../shared/classified-error'
|
||||
import type { IssueSourcePreference } from '../../../../shared/repo-types'
|
||||
import { type ParsedTaskQuery } from '../../../../shared/task-query'
|
||||
import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items'
|
||||
import { type LocalGitExecOptions, type OwnerRepo } from '../../gh-utils'
|
||||
import { getGitHubApiRepositoryForRemote, getOriginGitHubApiRepository } from '../../github-api-repository'
|
||||
import { WORK_ITEM_PR_LIST_JSON_FIELDS, type MainWorkItem } from './../map/work-item-field-coercion'
|
||||
import { WORK_ITEM_NUMBER_SORT_QUALIFIER, quoteGitHubSearchValue } from './work-item-search-query'
|
||||
export type WorkItemListRequest = {
|
||||
args: string[]
|
||||
offset: number
|
||||
}
|
||||
|
||||
export function normalizeWorkItemPage(page: number | undefined): number {
|
||||
return typeof page === 'number' && Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1
|
||||
}
|
||||
|
||||
export function buildWorkItemListRequest(args: {
|
||||
kind: 'issue' | 'pr'
|
||||
ownerRepo: OwnerRepo
|
||||
limit: number
|
||||
query: ParsedTaskQuery
|
||||
page: number
|
||||
}): WorkItemListRequest {
|
||||
const { kind, ownerRepo, limit, query, page } = args
|
||||
const searchParts: string[] = []
|
||||
|
||||
if (kind === 'issue') {
|
||||
searchParts.push(`repo:${ownerRepo.owner}/${ownerRepo.repo}`)
|
||||
}
|
||||
searchParts.push(kind === 'issue' ? 'is:issue' : 'is:pr')
|
||||
|
||||
if (query.state === 'open') {
|
||||
searchParts.push('is:open')
|
||||
} else if (query.state === 'closed') {
|
||||
searchParts.push('is:closed')
|
||||
if (kind === 'pr') {
|
||||
searchParts.push('-is:merged')
|
||||
}
|
||||
} else if (query.state === 'merged') {
|
||||
searchParts.push('is:merged')
|
||||
}
|
||||
|
||||
if (kind === 'pr' && query.draft) {
|
||||
searchParts.push('draft:true')
|
||||
}
|
||||
|
||||
if (query.assignee) {
|
||||
searchParts.push(`assignee:${quoteGitHubSearchValue(query.assignee)}`)
|
||||
}
|
||||
if (query.author) {
|
||||
searchParts.push(`author:${quoteGitHubSearchValue(query.author)}`)
|
||||
}
|
||||
if (query.labels.length > 0) {
|
||||
for (const label of query.labels) {
|
||||
searchParts.push(`label:${quoteGitHubSearchValue(label)}`)
|
||||
}
|
||||
}
|
||||
if (kind === 'pr' && query.reviewRequested) {
|
||||
searchParts.push(`review-requested:${quoteGitHubSearchValue(query.reviewRequested)}`)
|
||||
}
|
||||
if (kind === 'pr' && query.reviewedBy) {
|
||||
searchParts.push(`reviewed-by:${quoteGitHubSearchValue(query.reviewedBy)}`)
|
||||
}
|
||||
if (query.freeText) {
|
||||
searchParts.push(query.freeText)
|
||||
}
|
||||
|
||||
if (kind === 'issue') {
|
||||
return {
|
||||
args: [
|
||||
'api',
|
||||
'--cache',
|
||||
'120s',
|
||||
`search/issues?q=${encodeURIComponent(searchParts.join(' '))}&sort=created&order=desc&per_page=${limit}&page=${page}`,
|
||||
'--jq',
|
||||
'.items'
|
||||
],
|
||||
offset: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Why: search/issues omits the PR fields the Tasks columns need; use gh's rich PR list on a stable created sort.
|
||||
searchParts.push(WORK_ITEM_NUMBER_SORT_QUALIFIER)
|
||||
const out = [
|
||||
'pr',
|
||||
'list',
|
||||
'--limit',
|
||||
String(Math.min(page * limit, 1000)),
|
||||
'--state',
|
||||
'all',
|
||||
'--json',
|
||||
WORK_ITEM_PR_LIST_JSON_FIELDS
|
||||
]
|
||||
out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
|
||||
out.push('--search', searchParts.join(' '))
|
||||
return { args: out, offset: (page - 1) * limit }
|
||||
}
|
||||
|
||||
// Why: shared shape so listWorkItems can lift per-side errors (#1076 silent wrongness) into the IPC envelope — a swallowed side reads as end-of-data to pagination (#11485).
|
||||
export type PartialWorkItemsResult = {
|
||||
items: MainWorkItem[]
|
||||
issuesError?: ClassifiedError
|
||||
prsError?: ClassifiedError
|
||||
}
|
||||
|
||||
export function assertSshRepoHasResolvedGitHubSource(args: {
|
||||
connectionId?: string | null
|
||||
issueOwnerRepo: OwnerRepo | null
|
||||
prOwnerRepo: OwnerRepo | null
|
||||
}): void {
|
||||
if (!args.connectionId || args.issueOwnerRepo || args.prOwnerRepo) {
|
||||
return
|
||||
}
|
||||
// Why: SSH repo paths are remote-only, so without a resolved owner/repo gh would query local state.
|
||||
throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
|
||||
}
|
||||
|
||||
export type ResolvedPrWorkItemSource = {
|
||||
source: OwnerRepo | null
|
||||
originCandidate: OwnerRepo | null
|
||||
upstreamCandidate: OwnerRepo | null
|
||||
}
|
||||
|
||||
// Why: only an explicit `origin` preference is origin-only; `upstream`/`auto`/
|
||||
// undefined keep the multi-candidate probe ordered upstream-first, matching
|
||||
// resolvePrWorkItemSource list semantics.
|
||||
export async function resolvePrWorkItemSource(
|
||||
repoPath: string,
|
||||
preference: IssueSourcePreference | undefined,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<ResolvedPrWorkItemSource> {
|
||||
const [originCandidate, upstreamCandidate] = await Promise.all([
|
||||
getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions),
|
||||
getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions)
|
||||
])
|
||||
// Why: fork-contribution PRs live on the upstream repo (the fork's own PR
|
||||
// list is almost always empty), so 'auto' resolves upstream-first exactly
|
||||
// like the issue side. Only an explicit 'origin' pick pins PRs to the fork.
|
||||
const source = preference === 'origin' ? originCandidate : (upstreamCandidate ?? originCandidate)
|
||||
return { source, originCandidate, upstreamCandidate }
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { ClassifiedError } from '../../../../shared/classified-error'
|
||||
import { classifyGitHubUnavailable } from '../../../../shared/github/api-availability'
|
||||
import { parseTaskQuery, type ParsedTaskQuery } from '../../../../shared/task-query'
|
||||
import { sortWorkItemsByNumber } from '../../../../shared/work-items'
|
||||
import { ghExecFileAsync, classifyListIssuesError, classifyListPrsError, ghRepoExecOptions, githubRepoContext, type LocalGitExecOptions, type OwnerRepo } from '../../gh-utils'
|
||||
import { githubHostExecOptions } from '../../github-api-repository'
|
||||
import { hydrateWorkItemRepositoryMergeMetadata } from './../detect/hydrate-work-item-merge-metadata'
|
||||
import type { MainWorkItem } from './../map/work-item-field-coercion'
|
||||
import { mapIssueWorkItem, mapPullRequestWorkItem } from './../map/work-item'
|
||||
import { buildWorkItemListRequest, assertSshRepoHasResolvedGitHubSource, type PartialWorkItemsResult } from './work-item-list-request'
|
||||
export async function listRecentWorkItems(
|
||||
repoPath: string,
|
||||
issueOwnerRepo: OwnerRepo | null,
|
||||
prOwnerRepo: OwnerRepo | null,
|
||||
limit: number,
|
||||
page: number,
|
||||
connectionId?: string | null,
|
||||
noCache?: boolean,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<PartialWorkItemsResult> {
|
||||
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
|
||||
assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo })
|
||||
const recentQuery = parseTaskQuery('is:open')
|
||||
const issueRequest = issueOwnerRepo
|
||||
? buildWorkItemListRequest({
|
||||
kind: 'issue',
|
||||
ownerRepo: issueOwnerRepo,
|
||||
limit,
|
||||
query: recentQuery,
|
||||
page
|
||||
})
|
||||
: null
|
||||
const prRequest = prOwnerRepo
|
||||
? buildWorkItemListRequest({
|
||||
kind: 'pr',
|
||||
ownerRepo: prOwnerRepo,
|
||||
limit,
|
||||
query: recentQuery,
|
||||
page
|
||||
})
|
||||
: null
|
||||
if (noCache && issueRequest) {
|
||||
issueRequest.args.splice(1, 2)
|
||||
}
|
||||
// Why: unresolved sources must stay empty — an unscoped Search API would return other public repos' issues (#9660).
|
||||
// Why: allSettled so a 403 on the issue side doesn't zero the PR half (partial results + banner).
|
||||
const [issuesSettled, prsSettled] = await Promise.allSettled([
|
||||
issueRequest && issueOwnerRepo
|
||||
? ghExecFileAsync(issueRequest.args, {
|
||||
...ghOptions,
|
||||
...githubHostExecOptions(issueOwnerRepo)
|
||||
})
|
||||
: Promise.resolve({ stdout: '[]' }),
|
||||
prRequest && prOwnerRepo
|
||||
? ghExecFileAsync(prRequest.args, {
|
||||
...ghOptions,
|
||||
...githubHostExecOptions(prOwnerRepo)
|
||||
})
|
||||
: Promise.resolve({ stdout: '[]' })
|
||||
])
|
||||
|
||||
let issues: MainWorkItem[] = []
|
||||
let issuesError: ClassifiedError | undefined
|
||||
if (issuesSettled.status === 'fulfilled') {
|
||||
issues = (JSON.parse(issuesSettled.value.stdout) as Record<string, unknown>[])
|
||||
// Why: search/issues can still return PRs (pull_request marker) even with is:issue; filter them out.
|
||||
.filter((item) => !('pull_request' in item))
|
||||
.map(mapIssueWorkItem)
|
||||
} else {
|
||||
const stderr =
|
||||
issuesSettled.reason instanceof Error
|
||||
? issuesSettled.reason.message
|
||||
: String(issuesSettled.reason)
|
||||
issuesError = classifyListIssuesError(stderr)
|
||||
}
|
||||
|
||||
let prs: MainWorkItem[] = []
|
||||
if (prsSettled.status === 'fulfilled') {
|
||||
prs = (JSON.parse(prsSettled.value.stdout) as Record<string, unknown>[])
|
||||
.slice(prRequest?.offset ?? 0, (prRequest?.offset ?? 0) + limit)
|
||||
.map((item) => mapPullRequestWorkItem(item, prOwnerRepo))
|
||||
prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, {
|
||||
...ghOptions,
|
||||
...githubHostExecOptions(prOwnerRepo)
|
||||
})
|
||||
} else {
|
||||
// Why: re-throw PR errors so the cross-repo aggregator counts the repo failed; this feature only fixes issue-side swallowing (#1076).
|
||||
// Why: log issuesError first so a both-sides-failed case isn't blind to the classification we're about to drop.
|
||||
if (issuesError) {
|
||||
console.warn(
|
||||
'listRecentWorkItems: both issue and PR sides failed; issuesError was classified:',
|
||||
issuesError.type,
|
||||
issuesError.message
|
||||
)
|
||||
}
|
||||
throw prsSettled.reason
|
||||
}
|
||||
|
||||
return {
|
||||
items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit),
|
||||
issuesError
|
||||
}
|
||||
}
|
||||
|
||||
export async function listQueriedWorkItems(
|
||||
repoPath: string,
|
||||
issueOwnerRepo: OwnerRepo | null,
|
||||
prOwnerRepo: OwnerRepo | null,
|
||||
query: ParsedTaskQuery,
|
||||
limit: number,
|
||||
page?: number,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<PartialWorkItemsResult> {
|
||||
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
|
||||
assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo })
|
||||
const hasPrOnlyFilter =
|
||||
query.state === 'merged' ||
|
||||
query.draft ||
|
||||
query.reviewRequested !== null ||
|
||||
query.reviewedBy !== null
|
||||
const issueScope = query.scope !== 'pr' && !hasPrOnlyFilter
|
||||
const prScope = query.scope !== 'issue'
|
||||
let successfulRequestCount = 0
|
||||
let nonAvailabilityFailureCount = 0
|
||||
let availabilityError: unknown
|
||||
let prsError: ClassifiedError | undefined
|
||||
|
||||
// Why: surface the issue-side error separately for the IPC envelope; PR-side keeps prior swallow-and-log (parent doc §6).
|
||||
const issueFetch = (async (): Promise<PartialWorkItemsResult> => {
|
||||
if (!issueScope) {
|
||||
return { items: [] }
|
||||
}
|
||||
if (!issueOwnerRepo) {
|
||||
return { items: [] }
|
||||
}
|
||||
const request = buildWorkItemListRequest({
|
||||
kind: 'issue',
|
||||
ownerRepo: issueOwnerRepo,
|
||||
limit,
|
||||
query,
|
||||
page: page ?? 1
|
||||
})
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(request.args, {
|
||||
...ghOptions,
|
||||
...githubHostExecOptions(issueOwnerRepo)
|
||||
})
|
||||
const items = (JSON.parse(stdout) as Record<string, unknown>[])
|
||||
.filter((item) => !('pull_request' in item))
|
||||
.map(mapIssueWorkItem)
|
||||
successfulRequestCount += 1
|
||||
return { items }
|
||||
} catch (err) {
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
if (classifyGitHubUnavailable(stderr)) {
|
||||
availabilityError ??= err
|
||||
} else {
|
||||
nonAvailabilityFailureCount += 1
|
||||
}
|
||||
return { items: [], issuesError: classifyListIssuesError(stderr) }
|
||||
}
|
||||
})()
|
||||
|
||||
const prFetch = (async (): Promise<MainWorkItem[]> => {
|
||||
if (!prScope) {
|
||||
return []
|
||||
}
|
||||
if (!prOwnerRepo) {
|
||||
return []
|
||||
}
|
||||
const request = buildWorkItemListRequest({
|
||||
kind: 'pr',
|
||||
ownerRepo: prOwnerRepo,
|
||||
limit,
|
||||
query,
|
||||
page: page ?? 1
|
||||
})
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(request.args, {
|
||||
...ghOptions,
|
||||
...githubHostExecOptions(prOwnerRepo)
|
||||
})
|
||||
const mapped = (JSON.parse(stdout) as Record<string, unknown>[])
|
||||
.slice(request.offset, request.offset + limit)
|
||||
.map((item) => mapPullRequestWorkItem(item, prOwnerRepo))
|
||||
const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, {
|
||||
...ghOptions,
|
||||
...githubHostExecOptions(prOwnerRepo)
|
||||
})
|
||||
successfulRequestCount += 1
|
||||
if (query.state === 'closed') {
|
||||
return hydrated.filter((item) => item.state !== 'merged')
|
||||
}
|
||||
return hydrated
|
||||
} catch (err) {
|
||||
console.warn('listQueriedWorkItems PRs partial failure:', err)
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
prsError = classifyListPrsError(stderr)
|
||||
if (classifyGitHubUnavailable(stderr)) {
|
||||
availabilityError ??= err
|
||||
} else {
|
||||
nonAvailabilityFailureCount += 1
|
||||
}
|
||||
return []
|
||||
}
|
||||
})()
|
||||
|
||||
const [issueResult, prItems] = await Promise.all([issueFetch, prFetch])
|
||||
if (availabilityError && successfulRequestCount === 0 && nonAvailabilityFailureCount === 0) {
|
||||
// Why: when every half hit the same availability failure, propagate it so Tasks can distinguish an outage from no data.
|
||||
throw availabilityError
|
||||
}
|
||||
return {
|
||||
items: sortWorkItemsByNumber([...issueResult.items, ...prItems]).slice(0, limit),
|
||||
issuesError: issueResult.issuesError,
|
||||
prsError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type ParsedTaskQuery } from '../../../../shared/task-query'
|
||||
// Why: issue numbers follow creation order, so this sort aligns gh's PR rows with numbered Search API issue pages.
|
||||
export const WORK_ITEM_NUMBER_SORT_QUALIFIER = 'sort:created-desc'
|
||||
|
||||
export function buildSearchQueryString(
|
||||
ownerRepo: { owner: string; repo: string },
|
||||
query: ParsedTaskQuery
|
||||
): string {
|
||||
const parts: string[] = [`repo:${ownerRepo.owner}/${ownerRepo.repo}`]
|
||||
if (query.scope === 'pr') {
|
||||
parts.push('is:pull-request')
|
||||
} else if (query.scope === 'issue') {
|
||||
parts.push('is:issue')
|
||||
}
|
||||
if (query.state === 'open') {
|
||||
parts.push('is:open')
|
||||
} else if (query.state === 'closed') {
|
||||
// Why: GitHub search treats merged PRs as closed; exclude merged so "Closed" means closed-without-merge.
|
||||
parts.push('is:closed')
|
||||
if (query.scope !== 'issue') {
|
||||
parts.push('-is:merged')
|
||||
}
|
||||
} else if (query.state === 'merged') {
|
||||
parts.push('is:merged')
|
||||
}
|
||||
if (query.draft) {
|
||||
parts.push('draft:true')
|
||||
}
|
||||
if (query.assignee) {
|
||||
parts.push(`assignee:${quoteGitHubSearchValue(query.assignee)}`)
|
||||
}
|
||||
if (query.author) {
|
||||
parts.push(`author:${quoteGitHubSearchValue(query.author)}`)
|
||||
}
|
||||
if (query.reviewRequested) {
|
||||
parts.push(`review-requested:${quoteGitHubSearchValue(query.reviewRequested)}`)
|
||||
}
|
||||
if (query.reviewedBy) {
|
||||
parts.push(`reviewed-by:${quoteGitHubSearchValue(query.reviewedBy)}`)
|
||||
}
|
||||
for (const label of query.labels) {
|
||||
parts.push(`label:${quoteGitHubSearchValue(label)}`)
|
||||
}
|
||||
if (query.freeText) {
|
||||
parts.push(query.freeText)
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function quoteGitHubSearchValue(value: string): string {
|
||||
return /[\s"]/.test(value) ? `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` : value
|
||||
}
|
||||
|
||||
export function defaultOpenWorkItemQuery(): ParsedTaskQuery {
|
||||
return {
|
||||
scope: 'all',
|
||||
state: 'open',
|
||||
draft: false,
|
||||
assignee: null,
|
||||
author: null,
|
||||
reviewRequested: null,
|
||||
reviewedBy: null,
|
||||
labels: [],
|
||||
freeText: ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import type { PRRefreshOutcome } from '../../../../shared/github/pull-request-refresh-types'
|
||||
import { getPRConflictSummary } from '../../conflict-summary'
|
||||
import { ghRepoExecOptions, type OwnerRepo } from '../../gh-utils'
|
||||
import { isCommitPartOfMergedPR, type MergedPRCommitMembership } from '../../merged-pr-commit-membership'
|
||||
import { shouldHideNonOpenReviewOnDefaultBranch } from '../../../source-control/repo-default-branch'
|
||||
import { getGitHubApiRepositoryForRemote, resolveGitHubApiRepositoryCandidates } from '../../github-api-repository'
|
||||
import { mapPRState, deriveCheckStatus } from '../../mappers'
|
||||
import { noteRepositoryRateLimitSpend } from '../../rate-limit'
|
||||
import { hydrateGitHubPRStack } from '../../github-pr-stack'
|
||||
import { ownerRepoFromPullRequestUrl } from './../github-exec-scope'
|
||||
import { prRefreshUpstreamError } from './../gh-error-predicates'
|
||||
import { detectRepositoryMergeMetadata } from './../detect/repository-merge-metadata'
|
||||
import { derivePullRequestMergeable, isMergedImplicitPR, shouldHideMergedImplicitPR, getCurrentHeadOid, type PullRequestLookupData, type GitHubPRBranchLookupOptions } from './pull-request-lookup-data'
|
||||
import { lookupPRByBranchName } from './pr-branch-lookup'
|
||||
import { lookupPRByNumber } from './pr-number-lookup'
|
||||
import { getCachedGitHubPRStackSummary } from './pr-stack-summary-cache'
|
||||
import { shouldRetryTrackedUpstreamBranch } from './tracked-upstream-cache'
|
||||
import { getTrackedUpstreamBranch } from './tracked-upstream-branch'
|
||||
import { PR_BRANCH_LOOKUP_BUCKETS } from './pr-lookup-rate-limit'
|
||||
import type { HostedReviewLocalGitOptions } from './../github-exec-scope'
|
||||
export async function resolvePRForBranchOutcome(input: {
|
||||
repoPath: string
|
||||
branchName: string
|
||||
linkedPRNumber?: number | null
|
||||
connectionId?: string | null
|
||||
fallbackPRNumber?: number | null
|
||||
options: GitHubPRBranchLookupOptions
|
||||
localGitOptions: HostedReviewLocalGitOptions
|
||||
ghOptions: ReturnType<typeof ghRepoExecOptions>
|
||||
executionScope: string
|
||||
}): Promise<PRRefreshOutcome> {
|
||||
const { repoPath, branchName, linkedPRNumber, connectionId, fallbackPRNumber, options, localGitOptions, ghOptions, executionScope } = input
|
||||
const { candidates, headRepo } = await resolveGitHubApiRepositoryCandidates(
|
||||
repoPath,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
// Why: connection-backed gh runs without a repository cwd. A bare lookup
|
||||
// here can honor process GH_REPO/GH_HOST and return an unrelated PR.
|
||||
if (connectionId && candidates.length === 0) {
|
||||
return { kind: 'no-pr', fetchedAt: Date.now() }
|
||||
}
|
||||
// Why (#11532): account every lookup, not just the coordinator's queue —
|
||||
// `hostedReview:forBranch` reaches this directly from renderer polling and
|
||||
// was spending the shared quota invisibly. headRepo is `origin`, the same
|
||||
// identity the coordinator guards on.
|
||||
for (const bucket of PR_BRANCH_LOOKUP_BUCKETS) {
|
||||
noteRepositoryRateLimitSpend(headRepo ?? candidates[0], bucket, 1, ghOptions)
|
||||
}
|
||||
let data: PullRequestLookupData | null = null
|
||||
let dataRepo: OwnerRepo | null = null
|
||||
let dataHeadRepo: OwnerRepo | null = headRepo
|
||||
let pendingBranchLookupError: unknown
|
||||
let hasPendingBranchLookupError = false
|
||||
let currentHeadOidForMergedImplicit: string | null | undefined
|
||||
let usedExactNumberLookup = false
|
||||
|
||||
const explicitCurrentHeadOid =
|
||||
typeof options.currentHeadOid === 'string' && options.currentHeadOid.trim().length > 0
|
||||
? options.currentHeadOid.trim()
|
||||
: null
|
||||
let confirmedContainedHeadOid: string | null = null
|
||||
let headDivergedFromMergedPRAtOid: string | null = null
|
||||
const mergedPRContainsHead = async (
|
||||
candidate: PullRequestLookupData,
|
||||
candidateRepo: OwnerRepo | null,
|
||||
headOid: string | null
|
||||
): Promise<MergedPRCommitMembership> => {
|
||||
if (!candidateRepo || !headOid) {
|
||||
return 'unknown'
|
||||
}
|
||||
const membership = await isCommitPartOfMergedPR({
|
||||
ownerRepo: candidateRepo,
|
||||
prNumber: candidate.number,
|
||||
commitOid: headOid,
|
||||
ghOptions
|
||||
})
|
||||
if (membership === 'contained') {
|
||||
confirmedContainedHeadOid = headOid
|
||||
}
|
||||
return membership
|
||||
}
|
||||
const recordLinkedMergedPRDivergence = async (
|
||||
candidate: PullRequestLookupData | null,
|
||||
candidateRepo: OwnerRepo | null
|
||||
): Promise<void> => {
|
||||
if (
|
||||
typeof linkedPRNumber !== 'number' ||
|
||||
!candidate ||
|
||||
mapPRState(candidate.state, candidate.isDraft) !== 'merged' ||
|
||||
explicitCurrentHeadOid === null ||
|
||||
candidate.headRefOid === explicitCurrentHeadOid
|
||||
) {
|
||||
return
|
||||
}
|
||||
const membership = await mergedPRContainsHead(
|
||||
candidate,
|
||||
candidateRepo ?? ownerRepoFromPullRequestUrl(candidate.url),
|
||||
explicitCurrentHeadOid
|
||||
)
|
||||
if (membership === 'not-contained') {
|
||||
// explicitCurrentHeadOid is non-null here (guarded above); record the exact diverged head so consumers clear only that worktree.
|
||||
headDivergedFromMergedPRAtOid = explicitCurrentHeadOid
|
||||
}
|
||||
}
|
||||
const hideMergedImplicitPR = async (
|
||||
candidate: PullRequestLookupData | null,
|
||||
candidateRepo: OwnerRepo | null
|
||||
) => {
|
||||
if (!candidate || !isMergedImplicitPR(candidate, linkedPRNumber)) {
|
||||
return false
|
||||
}
|
||||
// Why: prefer the caller's worktree HEAD; only shell out (main repo path) when no explicit oid, keeping merged-at-head PRs visible for secondary worktrees.
|
||||
currentHeadOidForMergedImplicit ??=
|
||||
explicitCurrentHeadOid !== null
|
||||
? explicitCurrentHeadOid
|
||||
: await getCurrentHeadOid(repoPath, connectionId, localGitOptions)
|
||||
if (!shouldHideMergedImplicitPR(candidate, linkedPRNumber, currentHeadOidForMergedImplicit)) {
|
||||
return false
|
||||
}
|
||||
// Why: a head that is one of the PR's own commits (update-branch/web commits) is the same work, not a reused branch name — keep the merged PR visible.
|
||||
return (
|
||||
(await mergedPRContainsHead(candidate, candidateRepo, currentHeadOidForMergedImplicit)) !==
|
||||
'contained'
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof linkedPRNumber === 'number') {
|
||||
usedExactNumberLookup = true
|
||||
const exactLookup = await lookupPRByNumber({
|
||||
candidates,
|
||||
number: linkedPRNumber,
|
||||
ghOptions,
|
||||
executionScope
|
||||
})
|
||||
data = exactLookup.data
|
||||
dataRepo = exactLookup.dataRepo
|
||||
} else if (branchName) {
|
||||
// During a rebase (detached HEAD) branch is empty; an empty --head filter makes gh return an arbitrary PR.
|
||||
const branchLookup = await lookupPRByBranchName({
|
||||
candidates,
|
||||
headRepo,
|
||||
branchName,
|
||||
ghOptions,
|
||||
executionScope
|
||||
})
|
||||
data = branchLookup.data
|
||||
dataRepo = branchLookup.dataRepo
|
||||
if ('pendingError' in branchLookup) {
|
||||
pendingBranchLookupError = branchLookup.pendingError
|
||||
hasPendingBranchLookupError = true
|
||||
}
|
||||
if (!data) {
|
||||
// Why: the tracked upstream identifies the real PR head by branch name or fork owner even when local branch names match.
|
||||
const upstreamBranch = await getTrackedUpstreamBranch(
|
||||
repoPath,
|
||||
branchName,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (upstreamBranch) {
|
||||
const upstreamHeadRepo =
|
||||
(await getGitHubApiRepositoryForRemote(
|
||||
repoPath,
|
||||
upstreamBranch.remoteName,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)) ?? headRepo
|
||||
if (
|
||||
upstreamHeadRepo &&
|
||||
shouldRetryTrackedUpstreamBranch(upstreamBranch, branchName, upstreamHeadRepo, headRepo)
|
||||
) {
|
||||
const upstreamLookup = await lookupPRByBranchName({
|
||||
candidates,
|
||||
headRepo: upstreamHeadRepo,
|
||||
branchName: upstreamBranch.branchName,
|
||||
ghOptions,
|
||||
executionScope
|
||||
})
|
||||
data = upstreamLookup.data
|
||||
dataRepo = upstreamLookup.dataRepo
|
||||
if (!hasPendingBranchLookupError && 'pendingError' in upstreamLookup) {
|
||||
pendingBranchLookupError = upstreamLookup.pendingError
|
||||
hasPendingBranchLookupError = true
|
||||
}
|
||||
if (data) {
|
||||
dataHeadRepo = upstreamHeadRepo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mergedBranchLookupNumber: number | null = null
|
||||
if (await hideMergedImplicitPR(data, dataRepo)) {
|
||||
mergedBranchLookupNumber = data?.number ?? null
|
||||
data = null
|
||||
dataRepo = null
|
||||
dataHeadRepo = headRepo
|
||||
}
|
||||
if (!data && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber === 'number') {
|
||||
usedExactNumberLookup = true
|
||||
const fallbackLookup = await lookupPRByNumber({
|
||||
candidates,
|
||||
number: fallbackPRNumber,
|
||||
ghOptions,
|
||||
executionScope
|
||||
})
|
||||
data = fallbackLookup.data
|
||||
dataRepo = fallbackLookup.dataRepo
|
||||
}
|
||||
if (!data) {
|
||||
if (hasPendingBranchLookupError) {
|
||||
return prRefreshUpstreamError(pendingBranchLookupError)
|
||||
}
|
||||
return { kind: 'no-pr', fetchedAt: Date.now() }
|
||||
}
|
||||
await recordLinkedMergedPRDivergence(data, dataRepo)
|
||||
const fallbackConfirmedMergedBranch =
|
||||
typeof fallbackPRNumber === 'number' &&
|
||||
mergedBranchLookupNumber === fallbackPRNumber &&
|
||||
data.number === fallbackPRNumber
|
||||
const explicitHeadHidesMergedImplicitPR =
|
||||
explicitCurrentHeadOid !== null &&
|
||||
shouldHideMergedImplicitPR(data, linkedPRNumber, explicitCurrentHeadOid) &&
|
||||
(await mergedPRContainsHead(data, dataRepo, explicitCurrentHeadOid)) !== 'contained'
|
||||
// Why no lazy-HEAD re-check: fallback numbers were already gated on head equality/containment; re-hiding would blank kept deleted-head merged PRs.
|
||||
const shouldPreserveMergedFallback =
|
||||
!explicitHeadHidesMergedImplicitPR &&
|
||||
(fallbackConfirmedMergedBranch || options.acceptMergedFallbackPR === true)
|
||||
// Why: a visible PR can be merged outside Orca; keep a caller-marked fallback fresh even when GitHub no longer reports it by branch (e.g. deleted heads).
|
||||
if ((await hideMergedImplicitPR(data, dataRepo)) && !shouldPreserveMergedFallback) {
|
||||
return { kind: 'no-pr', fetchedAt: Date.now() }
|
||||
}
|
||||
// Why (#9171): on the default branch an implicit branch/fallback match must
|
||||
// never surface a non-open PR — it overrides the merged-fallback
|
||||
// preservation and merged-at-head carve-out on the trunk only. An exact
|
||||
// linked lookup returns the linked number, so linked PRs are exempt.
|
||||
if (
|
||||
await shouldHideNonOpenReviewOnDefaultBranch({
|
||||
state: mapPRState(data.state, data.isDraft),
|
||||
reviewNumber: data.number,
|
||||
linkedReviewNumber: linkedPRNumber,
|
||||
branchName,
|
||||
repoPath,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
})
|
||||
) {
|
||||
return { kind: 'no-pr', fetchedAt: Date.now() }
|
||||
}
|
||||
|
||||
if (!data.stackMetadataChecked && dataRepo && usedExactNumberLookup) {
|
||||
try {
|
||||
data.stack = await getCachedGitHubPRStackSummary(
|
||||
dataRepo,
|
||||
data.number,
|
||||
ghOptions,
|
||||
executionScope
|
||||
)
|
||||
data.stackMetadataChecked = true
|
||||
} catch {
|
||||
// Stack metadata is additive; exact PR lookup remains usable without it.
|
||||
}
|
||||
}
|
||||
const mergeable = derivePullRequestMergeable(data)
|
||||
const stack =
|
||||
data.stack && dataRepo
|
||||
? await hydrateGitHubPRStack(
|
||||
dataRepo,
|
||||
data.number,
|
||||
data.stack,
|
||||
ghOptions,
|
||||
data.updatedAt,
|
||||
executionScope
|
||||
)
|
||||
: data.stack
|
||||
const stackMergeQueueRequired =
|
||||
stack && dataRepo
|
||||
? (
|
||||
await detectRepositoryMergeMetadata(
|
||||
dataRepo,
|
||||
stack.baseRefName,
|
||||
ghOptions,
|
||||
executionScope
|
||||
)
|
||||
).mergeQueueRequired
|
||||
: undefined
|
||||
const conflictSummary =
|
||||
!connectionId &&
|
||||
mergeable === 'CONFLICTING' &&
|
||||
data.baseRefName &&
|
||||
data.baseRefOid &&
|
||||
data.headRefOid
|
||||
? await getPRConflictSummary(
|
||||
repoPath,
|
||||
data.baseRefName,
|
||||
data.baseRefOid,
|
||||
data.headRefOid,
|
||||
localGitOptions
|
||||
)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
kind: 'found',
|
||||
fetchedAt: Date.now(),
|
||||
pr: {
|
||||
number: data.number,
|
||||
title: data.title,
|
||||
state: mapPRState(data.state, data.isDraft),
|
||||
url: data.url,
|
||||
checksStatus: deriveCheckStatus(data.statusCheckRollup),
|
||||
updatedAt: data.updatedAt,
|
||||
mergeable,
|
||||
...(data.reviewDecision !== undefined ? { reviewDecision: data.reviewDecision } : {}),
|
||||
...(data.autoMergeEnabled !== undefined ? { autoMergeEnabled: data.autoMergeEnabled } : {}),
|
||||
...(data.autoMergeAllowed !== undefined ? { autoMergeAllowed: data.autoMergeAllowed } : {}),
|
||||
...(stackMergeQueueRequired !== undefined || data.mergeQueueRequired !== undefined
|
||||
? {
|
||||
mergeQueueRequired:
|
||||
stackMergeQueueRequired !== undefined
|
||||
? stackMergeQueueRequired
|
||||
: data.mergeQueueRequired
|
||||
}
|
||||
: {}),
|
||||
...(data.mergeMethodSettings !== undefined
|
||||
? { mergeMethodSettings: data.mergeMethodSettings }
|
||||
: {}),
|
||||
...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}),
|
||||
...(stack ? { stack } : {}),
|
||||
headSha: data.headRefOid,
|
||||
...(confirmedContainedHeadOid ? { confirmedContainedHeadOid } : {}),
|
||||
...(headDivergedFromMergedPRAtOid ? { headDivergedFromMergedPRAtOid } : {}),
|
||||
...(data.baseRefName ? { baseRefName: data.baseRefName } : {}),
|
||||
...(data.headRefName ? { headRefName: data.headRefName } : {}),
|
||||
prRepo: dataRepo ?? undefined,
|
||||
headRepo: dataHeadRepo ?? undefined,
|
||||
conflictSummary
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PRInfo } from '../../../../shared/github/pull-request-types'
|
||||
import type { GitHubPRBranchLookupOptions } from './pull-request-lookup-data'
|
||||
import { getPRForBranchOutcome } from './pr-for-branch-outcome'
|
||||
/**
|
||||
* Get PR info for a given branch using gh CLI.
|
||||
* Returns null if gh is not installed, or no PR exists for the branch.
|
||||
*
|
||||
* When `linkedPRNumber` is provided, it is the source of truth. This handles
|
||||
* "create from PR" worktrees whose local branch differs from the PR head ref,
|
||||
* and prevents a coalesced linked-PR refresh from fanning out an unrelated
|
||||
* branch lookup result to sibling aliases.
|
||||
* `fallbackPRNumber` is weaker: branch lookup still wins, and exact lookup is
|
||||
* used only after branch lookup misses.
|
||||
*/
|
||||
export async function getPRForBranch(
|
||||
repoPath: string,
|
||||
branch: string,
|
||||
linkedPRNumber?: number | null,
|
||||
connectionId?: string | null,
|
||||
fallbackPRNumber?: number | null,
|
||||
options: GitHubPRBranchLookupOptions = {}
|
||||
): Promise<PRInfo | null> {
|
||||
const outcome = await getPRForBranchOutcome(
|
||||
repoPath,
|
||||
branch,
|
||||
linkedPRNumber,
|
||||
connectionId,
|
||||
fallbackPRNumber,
|
||||
options
|
||||
)
|
||||
return outcome.kind === 'found' ? outcome.pr : null
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ghExecFileAsync, ghRepoExecOptions, type OwnerRepo } from '../../gh-utils'
|
||||
import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import type { GhExecOptions } from './../github-exec-scope'
|
||||
import { isNoPullRequestError } from './../gh-error-predicates'
|
||||
import { PR_LOOKUP_JSON_FIELDS, PR_BRANCH_LIST_JSON_FIELDS, mapRestPullRequest, normalizePullRequestLookupData, type PullRequestLookupData, type RestPullRequest } from './pull-request-lookup-data'
|
||||
import { getPRByNumber } from './pr-number-lookup'
|
||||
export async function getRestPRForBranch(
|
||||
prRepo: GitHubApiRepository,
|
||||
headOwner: string,
|
||||
branchName: string,
|
||||
ghOptions: ReturnType<typeof ghRepoExecOptions>
|
||||
): Promise<PullRequestLookupData | null> {
|
||||
const head = encodeURIComponent(`${headOwner}:${branchName}`)
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', `repos/${prRepo.owner}/${prRepo.repo}/pulls?head=${head}&state=all&per_page=1`],
|
||||
{ ...ghOptions, ...githubHostExecOptions(prRepo) }
|
||||
)
|
||||
const list = JSON.parse(stdout) as RestPullRequest[]
|
||||
const pr = list[0]
|
||||
return pr ? mapRestPullRequest(pr) : null
|
||||
}
|
||||
|
||||
export async function getFallbackPRListForBranch(
|
||||
prRepo: GitHubApiRepository,
|
||||
branchName: string,
|
||||
ghOptions: ReturnType<typeof ghRepoExecOptions>
|
||||
): Promise<PullRequestLookupData | null> {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'pr',
|
||||
'list',
|
||||
'--repo',
|
||||
`${prRepo.owner}/${prRepo.repo}`,
|
||||
'--head',
|
||||
branchName,
|
||||
'--state',
|
||||
'all',
|
||||
'--limit',
|
||||
'1',
|
||||
'--json',
|
||||
PR_BRANCH_LIST_JSON_FIELDS
|
||||
],
|
||||
{ ...ghOptions, ...githubHostExecOptions(prRepo) }
|
||||
)
|
||||
const list = JSON.parse(stdout) as PullRequestLookupData[]
|
||||
return list[0] ?? null
|
||||
}
|
||||
|
||||
export async function hydrateBranchLookupWithExactPR(
|
||||
ownerRepo: OwnerRepo,
|
||||
branchData: PullRequestLookupData | null,
|
||||
ghOptions: GhExecOptions,
|
||||
executionScope: string
|
||||
): Promise<PullRequestLookupData | null> {
|
||||
if (!branchData) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return (
|
||||
(await getPRByNumber(ownerRepo, branchData.number, ghOptions, executionScope, branchData)) ??
|
||||
branchData
|
||||
)
|
||||
} catch {
|
||||
return branchData
|
||||
}
|
||||
}
|
||||
|
||||
export async function lookupPRByBranchName(args: {
|
||||
candidates: OwnerRepo[]
|
||||
headRepo: OwnerRepo | null
|
||||
branchName: string
|
||||
ghOptions: GhExecOptions
|
||||
executionScope: string
|
||||
}): Promise<{
|
||||
data: PullRequestLookupData | null
|
||||
dataRepo: OwnerRepo | null
|
||||
pendingError?: unknown
|
||||
}> {
|
||||
if (args.candidates.length > 0) {
|
||||
let pendingError: unknown
|
||||
let hasPendingError = false
|
||||
for (const candidate of args.candidates) {
|
||||
try {
|
||||
const branchData = args.headRepo
|
||||
? await getRestPRForBranch(
|
||||
candidate,
|
||||
args.headRepo.owner,
|
||||
args.branchName,
|
||||
args.ghOptions
|
||||
)
|
||||
: await getFallbackPRListForBranch(candidate, args.branchName, args.ghOptions)
|
||||
// Why: REST/list branch lookup identifies the PR cheaply; exact `gh pr view` carries review, merge-queue, and auto-merge state.
|
||||
const data = await hydrateBranchLookupWithExactPR(
|
||||
candidate,
|
||||
branchData,
|
||||
args.ghOptions,
|
||||
args.executionScope
|
||||
)
|
||||
if (data) {
|
||||
return { data, dataRepo: candidate }
|
||||
}
|
||||
} catch (err) {
|
||||
if (args.headRepo) {
|
||||
throw err
|
||||
}
|
||||
if (!hasPendingError) {
|
||||
pendingError = err
|
||||
hasPendingError = true
|
||||
}
|
||||
try {
|
||||
const branchData = await getRestPRForBranch(
|
||||
candidate,
|
||||
candidate.owner,
|
||||
args.branchName,
|
||||
args.ghOptions
|
||||
)
|
||||
const data = await hydrateBranchLookupWithExactPR(
|
||||
candidate,
|
||||
branchData,
|
||||
args.ghOptions,
|
||||
args.executionScope
|
||||
)
|
||||
if (data) {
|
||||
return { data, dataRepo: candidate }
|
||||
}
|
||||
} catch (retryErr) {
|
||||
if (!hasPendingError) {
|
||||
pendingError = retryErr
|
||||
hasPendingError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: branch-list failures are ambiguous for fork discovery; give exact fallback-number recovery a chance before surfacing the error.
|
||||
return hasPendingError
|
||||
? { data: null, dataRepo: null, pendingError }
|
||||
: { data: null, dataRepo: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['pr', 'view', args.branchName, '--json', PR_LOOKUP_JSON_FIELDS],
|
||||
args.ghOptions
|
||||
)
|
||||
return {
|
||||
data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData),
|
||||
dataRepo: null
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNoPullRequestError(err)) {
|
||||
return { data: null, dataRepo: null }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { PRRefreshOutcome } from '../../../../shared/github/pull-request-refresh-types'
|
||||
import { acquire, release, ghRepoExecOptions, githubRepoContext } from '../../gh-utils'
|
||||
import { hostedReviewLocalGitOptionArgs, githubPRStackExecutionScope } from './../github-exec-scope'
|
||||
import type { GitHubPRBranchLookupOptions } from './pull-request-lookup-data'
|
||||
import { prRefreshUpstreamError } from './../gh-error-predicates'
|
||||
import { resolvePRForBranchOutcome } from './branch-lookup-resolution'
|
||||
export async function getPRForBranchOutcome(
|
||||
repoPath: string,
|
||||
branch: string,
|
||||
linkedPRNumber?: number | null,
|
||||
connectionId?: string | null,
|
||||
fallbackPRNumber?: number | null,
|
||||
options: GitHubPRBranchLookupOptions = {}
|
||||
): Promise<PRRefreshOutcome> {
|
||||
const branchName = branch.replace(/^refs\/heads\//, '')
|
||||
// Why: detached HEAD can't use branch lookup, but an exact linked/fallback PR number is still safe to query and keeps review state visible.
|
||||
if (!branchName && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber !== 'number') {
|
||||
return { kind: 'no-pr', fetchedAt: Date.now() }
|
||||
}
|
||||
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
|
||||
const localGitOptions = localGitArgs[0] ?? {}
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
const ghOptions = ghRepoExecOptions(context)
|
||||
const executionScope = githubPRStackExecutionScope(connectionId, localGitOptions)
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
return await resolvePRForBranchOutcome({ repoPath, branchName, linkedPRNumber, connectionId, fallbackPRNumber, options, localGitOptions, ghOptions, executionScope })
|
||||
} catch (err) {
|
||||
return prRefreshUpstreamError(err)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ghRepoExecOptions, githubRepoContext, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { getOriginGitHubApiRepository, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { getRateLimit, repositoryRateLimitGuard, spendsSharedGitHubComQuota, type RateLimitBucketKind } from '../../rate-limit'
|
||||
import type { GhExecOptions } from './../github-exec-scope'
|
||||
// Why: a branch lookup prefers REST but can fall back to `gh pr list` and
|
||||
// `gh pr view`, so both buckets are guarded and charged. Mirrors the PR refresh
|
||||
// coordinator's own estimate.
|
||||
export const PR_BRANCH_LOOKUP_BUCKETS = ['core', 'graphql'] as const
|
||||
|
||||
/**
|
||||
* Rate-limit floor for GitHub PR lookups that do not run through the PR refresh
|
||||
* coordinator's queue (#11532).
|
||||
*
|
||||
* The coordinator guards and paces its own background refreshes, but
|
||||
* `hostedReview:forBranch` polls the same lookup straight from the renderer.
|
||||
* Ungated, the two paths together could spend the user's entire hourly quota —
|
||||
* which is per user and shared with their own `gh` and CLI agents.
|
||||
* Returns the reset time when the caller must not spend, else `null`.
|
||||
*/
|
||||
export async function getGitHubPRLookupRateLimitBlock(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<{ resetAt: number } | null> {
|
||||
const executionOptions = ghRepoExecOptions(
|
||||
githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
)
|
||||
// Why: identity resolution runs local git, which can fail for reasons that
|
||||
// have nothing to do with the budget; let the lookup itself classify those.
|
||||
const repository = await getOriginGitHubApiRepository(
|
||||
repoPath,
|
||||
connectionId,
|
||||
executionOptions
|
||||
).catch(() => null)
|
||||
if (repository === null) {
|
||||
return null
|
||||
}
|
||||
if (spendsSharedGitHubComQuota(repository, executionOptions)) {
|
||||
// Why: the probe only warms the snapshot and is exempt from limits, so a
|
||||
// failure must fail open rather than block the lookup (#7553).
|
||||
await getRateLimit().catch(() => undefined)
|
||||
}
|
||||
// Why: retrying at the earlier reset would fail again on the bucket that has
|
||||
// not reset yet, so the latest blocked reset is the only honest retry time.
|
||||
const resets = PR_BRANCH_LOOKUP_BUCKETS.map((bucket) =>
|
||||
repositoryRateLimitGuard(repository, bucket, executionOptions)
|
||||
).flatMap((guard) => (guard.blocked ? [guard.resetAt] : []))
|
||||
return resets.length > 0 ? { resetAt: Math.max(...resets) } : null
|
||||
}
|
||||
|
||||
export async function assertRateLimitBudget(
|
||||
bucket: RateLimitBucketKind,
|
||||
repository?: GitHubApiRepository | null,
|
||||
executionOptions?: Pick<GhExecOptions, 'cwd' | 'wslDistro'>
|
||||
): Promise<void> {
|
||||
if (spendsSharedGitHubComQuota(repository, executionOptions)) {
|
||||
await getRateLimit()
|
||||
}
|
||||
const guard = repositoryRateLimitGuard(repository, bucket, executionOptions)
|
||||
if (guard.blocked) {
|
||||
throw new Error(
|
||||
`GitHub ${bucket} rate limit is low; retry after ${new Date(guard.resetAt * 1000).toLocaleTimeString()}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { ghExecFileAsync, ghRepoExecOptions, type OwnerRepo } from '../../gh-utils'
|
||||
import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { isNoPullRequestError, isNotFoundGhError, shouldStopAfterExactLookupError } from './../gh-error-predicates'
|
||||
import { PR_LOOKUP_JSON_FIELDS, mapRestPullRequest, normalizePullRequestLookupData, type PullRequestLookupData, type RestPullRequest } from './pull-request-lookup-data'
|
||||
import { hydratePullRequestLookupData } from './pull-request-lookup-hydration'
|
||||
import { isGitObjectId, isUsableRestStackMetadata } from './rest-stack-metadata-validation'
|
||||
export async function getRestPRByNumber(
|
||||
ownerRepo: GitHubApiRepository,
|
||||
number: number,
|
||||
ghOptions: ReturnType<typeof ghRepoExecOptions>,
|
||||
options: { requireUsableStackMetadata?: boolean } = {}
|
||||
): Promise<PullRequestLookupData> {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
|
||||
{ ...ghOptions, ...githubHostExecOptions(ownerRepo) }
|
||||
)
|
||||
const parsed = JSON.parse(stdout) as unknown
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error('invalid response shape')
|
||||
}
|
||||
const restData = parsed as RestPullRequest
|
||||
const mapped = mapRestPullRequest(restData)
|
||||
if (
|
||||
options.requireUsableStackMetadata &&
|
||||
restData.stack !== undefined &&
|
||||
restData.stack !== null
|
||||
) {
|
||||
// Why: GitHub omits stack for ordinary PRs; only unusable non-null metadata is unsafe.
|
||||
if (!isUsableRestStackMetadata(restData.stack) || !mapped.stack) {
|
||||
throw new Error('malformed stack')
|
||||
}
|
||||
if (!isGitObjectId(restData.head?.sha)) {
|
||||
throw new Error('missing head SHA')
|
||||
}
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
export async function getPRByNumber(
|
||||
ownerRepo: GitHubApiRepository,
|
||||
number: number,
|
||||
ghOptions: ReturnType<typeof ghRepoExecOptions>,
|
||||
executionScope: string,
|
||||
knownPullRequestData?: PullRequestLookupData | null
|
||||
): Promise<PullRequestLookupData | null> {
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'pr',
|
||||
'view',
|
||||
String(number),
|
||||
'--repo',
|
||||
`${ownerRepo.owner}/${ownerRepo.repo}`,
|
||||
'--json',
|
||||
PR_LOOKUP_JSON_FIELDS
|
||||
],
|
||||
{ ...ghOptions, ...githubHostExecOptions(ownerRepo) }
|
||||
)
|
||||
const exactData = JSON.parse(stdout) as PullRequestLookupData
|
||||
return hydratePullRequestLookupData(
|
||||
ownerRepo,
|
||||
{
|
||||
...knownPullRequestData,
|
||||
...exactData,
|
||||
...(knownPullRequestData?.stack ? { stack: knownPullRequestData.stack } : {})
|
||||
},
|
||||
ghOptions,
|
||||
executionScope
|
||||
)
|
||||
} catch (err) {
|
||||
// Why: deleted/edited linked PR metadata falls back to branch discovery; quota/auth/network failures get one cheaper REST exact lookup.
|
||||
if (isNotFoundGhError(err)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const restData =
|
||||
knownPullRequestData === undefined
|
||||
? await getRestPRByNumber(ownerRepo, number, ghOptions)
|
||||
: knownPullRequestData
|
||||
return restData
|
||||
? hydratePullRequestLookupData(ownerRepo, restData, ghOptions, executionScope)
|
||||
: null
|
||||
} catch (restErr) {
|
||||
if (isNotFoundGhError(restErr)) {
|
||||
return null
|
||||
}
|
||||
if (!shouldStopAfterExactLookupError(restErr)) {
|
||||
return null
|
||||
}
|
||||
throw restErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function lookupPRByNumber(args: {
|
||||
candidates: OwnerRepo[]
|
||||
number: number
|
||||
ghOptions: ReturnType<typeof ghRepoExecOptions>
|
||||
executionScope: string
|
||||
}): Promise<{ data: PullRequestLookupData | null; dataRepo: OwnerRepo | null }> {
|
||||
for (const candidate of args.candidates) {
|
||||
try {
|
||||
const linkedData = await getPRByNumber(
|
||||
candidate,
|
||||
args.number,
|
||||
args.ghOptions,
|
||||
args.executionScope
|
||||
)
|
||||
if (!linkedData) {
|
||||
continue
|
||||
}
|
||||
return { data: linkedData, dataRepo: candidate }
|
||||
} catch (err) {
|
||||
if (shouldStopAfterExactLookupError(err)) {
|
||||
throw err
|
||||
}
|
||||
// Candidate probing is best-effort; another repo may own the PR.
|
||||
}
|
||||
}
|
||||
|
||||
if (args.candidates.length > 0) {
|
||||
return { data: null, dataRepo: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['pr', 'view', String(args.number), '--json', PR_LOOKUP_JSON_FIELDS],
|
||||
args.ghOptions
|
||||
)
|
||||
return {
|
||||
data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData),
|
||||
dataRepo: null
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNoPullRequestError(err)) {
|
||||
// Why: stale cached fallback numbers shouldn't error every poll when the PR was deleted or belongs to another repo.
|
||||
return { data: null, dataRepo: null }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { GitHubPRStack } from '../../../../shared/github/pull-request-types'
|
||||
import { ghRepoExecOptions } from '../../gh-utils'
|
||||
import { type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key'
|
||||
import { getRestPRByNumber } from './pr-number-lookup'
|
||||
export const PR_STACK_SUMMARY_CACHE_TTL_MS = 60_000
|
||||
|
||||
export const PR_STACK_SUMMARY_CACHE_MAX_ENTRIES = 512
|
||||
|
||||
export const STACK_METADATA_UNAVAILABLE_ERROR =
|
||||
'Could not verify GitHub pull request stack metadata. Refresh and try again.'
|
||||
|
||||
export const prStackSummaryCache = new Map<
|
||||
string,
|
||||
{ value: GitHubPRStack | undefined; expiresAt: number }
|
||||
>()
|
||||
|
||||
export const prStackSummaryInFlight = new Map<string, Promise<GitHubPRStack | undefined>>()
|
||||
|
||||
export function prunePRStackSummaryCache(now = Date.now()): void {
|
||||
for (const [key, cached] of prStackSummaryCache) {
|
||||
if (cached.expiresAt <= now) {
|
||||
prStackSummaryCache.delete(key)
|
||||
}
|
||||
}
|
||||
while (prStackSummaryCache.size > PR_STACK_SUMMARY_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = prStackSummaryCache.keys().next().value
|
||||
if (oldestKey === undefined) {
|
||||
return
|
||||
}
|
||||
prStackSummaryCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCachedGitHubPRStackSummary(
|
||||
ownerRepo: GitHubApiRepository,
|
||||
number: number,
|
||||
ghOptions: ReturnType<typeof ghRepoExecOptions>,
|
||||
executionScope: string
|
||||
): Promise<GitHubPRStack | undefined> {
|
||||
const key = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}#${number}`
|
||||
const now = Date.now()
|
||||
prunePRStackSummaryCache(now)
|
||||
const cached = prStackSummaryCache.get(key)
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.value
|
||||
}
|
||||
const existing = prStackSummaryInFlight.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const request = getRestPRByNumber(ownerRepo, number, ghOptions).then((pr) => pr.stack)
|
||||
prStackSummaryInFlight.set(key, request)
|
||||
try {
|
||||
const value = await request
|
||||
prStackSummaryCache.delete(key)
|
||||
prStackSummaryCache.set(key, {
|
||||
value,
|
||||
expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS
|
||||
})
|
||||
prunePRStackSummaryCache()
|
||||
return value
|
||||
} catch (err) {
|
||||
// Why: avoid repeating a failed REST probe on every review poll.
|
||||
prStackSummaryCache.delete(key)
|
||||
prStackSummaryCache.set(key, {
|
||||
value: undefined,
|
||||
expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS
|
||||
})
|
||||
prunePRStackSummaryCache()
|
||||
throw err
|
||||
} finally {
|
||||
if (prStackSummaryInFlight.get(key) === request) {
|
||||
prStackSummaryInFlight.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function _resetPRStackSummaryCacheForTests(): void {
|
||||
prStackSummaryCache.clear()
|
||||
prStackSummaryInFlight.clear()
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { GitHubPRMergeMethodSettings, GitHubPRStack, PRMergeableState, PRReviewDecision } from '../../../../shared/github/pull-request-types'
|
||||
import { gitExecFileAsync } from '../../gh-utils'
|
||||
import { getSshGitProvider, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../../../providers/ssh-git-dispatch'
|
||||
import { type HostedReviewExecutionOptions } from '../../../source-control/hosted-review-git-options'
|
||||
import { mapPRState } from '../../mappers'
|
||||
import { normalizePRMergeable, normalizeReviewDecision, isAutoMergeEnabled } from './../map/work-item-field-coercion'
|
||||
export type PullRequestLookupData = {
|
||||
number: number
|
||||
title: string
|
||||
state: string
|
||||
url: string
|
||||
statusCheckRollup: unknown[]
|
||||
updatedAt: string
|
||||
isDraft?: boolean
|
||||
mergeable: string
|
||||
reviewDecision?: PRReviewDecision | null
|
||||
autoMergeRequest?: unknown
|
||||
autoMergeEnabled?: boolean
|
||||
autoMergeAllowed?: boolean | null
|
||||
mergeQueueRequired?: boolean | null
|
||||
mergeMethodSettings?: GitHubPRMergeMethodSettings
|
||||
mergeStateStatus?: string | null
|
||||
baseRefName?: string
|
||||
headRefName?: string
|
||||
baseRefOid?: string
|
||||
headRefOid?: string
|
||||
stack?: GitHubPRStack
|
||||
stackMetadataChecked?: boolean
|
||||
}
|
||||
|
||||
export type RestPullRequest = {
|
||||
number: number
|
||||
title: string
|
||||
state: string
|
||||
html_url?: string
|
||||
url?: string
|
||||
updated_at?: string
|
||||
draft?: boolean
|
||||
merged_at?: string | null
|
||||
mergeable?: boolean | null
|
||||
mergeable_state?: string | null
|
||||
base?: { ref?: string; sha?: string }
|
||||
head?: { ref?: string; sha?: string }
|
||||
stack?: {
|
||||
number?: number
|
||||
position?: number
|
||||
size?: number
|
||||
base?: { ref?: string; sha?: string }
|
||||
} | null
|
||||
}
|
||||
|
||||
export const PR_LOOKUP_JSON_FIELDS =
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
|
||||
export const PR_BRANCH_LIST_JSON_FIELDS =
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
|
||||
export type GitHubPRBranchLookupOptions = HostedReviewExecutionOptions & {
|
||||
acceptMergedFallbackPR?: boolean
|
||||
// Why: compare merged implicit PRs against the worktree HEAD, not main repo HEAD, without a worktree-scoped git call.
|
||||
currentHeadOid?: string | null
|
||||
}
|
||||
|
||||
export function mapRestPRMergeable(pr: RestPullRequest): PRMergeableState {
|
||||
const mergeableState = pr.mergeable_state?.toLowerCase()
|
||||
if (mergeableState === 'dirty') {
|
||||
return 'CONFLICTING'
|
||||
}
|
||||
if (mergeableState === 'clean' || pr.mergeable === true) {
|
||||
return 'MERGEABLE'
|
||||
}
|
||||
return 'UNKNOWN'
|
||||
}
|
||||
|
||||
export function derivePullRequestMergeable(data: PullRequestLookupData): PRMergeableState {
|
||||
const mergeable = normalizePRMergeable(data.mergeable)
|
||||
if (mergeable === 'CONFLICTING' || data.mergeStateStatus === 'DIRTY') {
|
||||
return 'CONFLICTING'
|
||||
}
|
||||
return mergeable ?? 'UNKNOWN'
|
||||
}
|
||||
|
||||
export function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData {
|
||||
const stack =
|
||||
typeof pr.stack?.number === 'number' &&
|
||||
typeof pr.stack.position === 'number' &&
|
||||
typeof pr.stack.size === 'number' &&
|
||||
typeof pr.stack.base?.ref === 'string'
|
||||
? {
|
||||
number: pr.stack.number,
|
||||
position: pr.stack.position,
|
||||
size: pr.stack.size,
|
||||
baseRefName: pr.stack.base.ref,
|
||||
...(typeof pr.stack.base.sha === 'string' ? { baseSha: pr.stack.base.sha } : {})
|
||||
}
|
||||
: undefined
|
||||
return {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
state: pr.merged_at ? 'MERGED' : pr.state,
|
||||
url: pr.html_url ?? pr.url ?? '',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: pr.updated_at ?? '',
|
||||
isDraft: pr.draft,
|
||||
mergeable: mapRestPRMergeable(pr),
|
||||
baseRefName: pr.base?.ref,
|
||||
headRefName: pr.head?.ref,
|
||||
baseRefOid: pr.base?.sha,
|
||||
headRefOid: pr.head?.sha,
|
||||
stackMetadataChecked: true,
|
||||
...(stack ? { stack } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function isMergedImplicitPR(data: PullRequestLookupData, linkedPRNumber?: number | null): boolean {
|
||||
// Why: a merged PR without an explicit link is just a historical branch match, not implicit review context.
|
||||
return typeof linkedPRNumber !== 'number' && mapPRState(data.state, data.isDraft) === 'merged'
|
||||
}
|
||||
|
||||
export function shouldHideMergedImplicitPR(
|
||||
data: PullRequestLookupData | null,
|
||||
linkedPRNumber: number | null | undefined,
|
||||
currentHeadOid: string | null
|
||||
): boolean {
|
||||
if (!data || !isMergedImplicitPR(data, linkedPRNumber)) {
|
||||
return false
|
||||
}
|
||||
// Why: keep hiding historical merged branch matches, but preserve the merged PR for the exact commit currently checked out.
|
||||
return !currentHeadOid || data.headRefOid !== currentHeadOid
|
||||
}
|
||||
|
||||
export function normalizePullRequestLookupData(data: PullRequestLookupData): PullRequestLookupData {
|
||||
return {
|
||||
...data,
|
||||
reviewDecision:
|
||||
data.reviewDecision !== undefined ? normalizeReviewDecision(data.reviewDecision) : undefined,
|
||||
autoMergeEnabled:
|
||||
data.autoMergeEnabled ??
|
||||
('autoMergeRequest' in data ? isAutoMergeEnabled(data.autoMergeRequest) : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentHeadOid(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: { wslDistro?: string } = {}
|
||||
): Promise<string | null> {
|
||||
const provider = connectionId ? getSshGitProvider(connectionId) : null
|
||||
if (connectionId && !provider) {
|
||||
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
if (provider) {
|
||||
const result = await provider.exec(['rev-parse', 'HEAD'], repoPath)
|
||||
return result.stdout.trim() || null
|
||||
}
|
||||
try {
|
||||
const result = await gitExecFileAsync(['rev-parse', 'HEAD'], {
|
||||
cwd: repoPath,
|
||||
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
|
||||
})
|
||||
return result.stdout.trim() || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { type OwnerRepo } from '../../gh-utils'
|
||||
import type { GhExecOptions } from './../github-exec-scope'
|
||||
import { detectRepositoryMergeMetadata } from './../detect/repository-merge-metadata'
|
||||
import { normalizePullRequestLookupData, type PullRequestLookupData } from './pull-request-lookup-data'
|
||||
export async function hydratePullRequestLookupData(
|
||||
ownerRepo: OwnerRepo,
|
||||
data: PullRequestLookupData,
|
||||
ghOptions: GhExecOptions,
|
||||
executionScope: string
|
||||
): Promise<PullRequestLookupData> {
|
||||
const normalized = normalizePullRequestLookupData(data)
|
||||
const hasRichMergeFields =
|
||||
'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data
|
||||
const mergeMetadata = hasRichMergeFields
|
||||
? await detectRepositoryMergeMetadata(
|
||||
ownerRepo,
|
||||
normalized.stack?.baseRefName ?? normalized.baseRefName,
|
||||
ghOptions,
|
||||
executionScope
|
||||
)
|
||||
: undefined
|
||||
return {
|
||||
...normalized,
|
||||
...(mergeMetadata ? { mergeQueueRequired: mergeMetadata.mergeQueueRequired } : {}),
|
||||
...(mergeMetadata ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } : {}),
|
||||
...(mergeMetadata?.mergeMethodSettings
|
||||
? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { IssueSourcePreference } from '../../../../shared/repo-types'
|
||||
import type { GitPushTarget } from '../../../../shared/worktree/types'
|
||||
import { ghExecFileAsync, acquire, release, ghRepoExecOptions, githubRepoContext, getRemoteUrlForRepo, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { getGitHubApiRepositoryForRemote, githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key'
|
||||
import { isNotFoundGhError } from './../gh-error-predicates'
|
||||
import { resolvePullRequestLookupCandidates } from './../pull-request-lookup-candidates'
|
||||
export function pickPushRemoteUrl(args: {
|
||||
originUrl: string | null
|
||||
cloneUrl: string
|
||||
sshUrl: string
|
||||
}): string {
|
||||
const { originUrl, cloneUrl, sshUrl } = args
|
||||
if (originUrl && (/^(git@|ssh:)/.test(originUrl) || originUrl.includes('ssh.github.com'))) {
|
||||
return sshUrl
|
||||
}
|
||||
return cloneUrl
|
||||
}
|
||||
|
||||
export function sanitizeRemoteName(owner: string, repo: string): string {
|
||||
const slug = `${owner}-${repo}`
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^[.-]+|[.-]+$/g, '')
|
||||
return slug ? `pr-${slug}` : 'pr-head'
|
||||
}
|
||||
|
||||
/**
|
||||
* A fork push target plus the PR's `maintainer_can_modify` flag, kept outside
|
||||
* {@link GitPushTarget} so it never leaks into the persisted push-target shape.
|
||||
*/
|
||||
export type PullRequestPushTarget = {
|
||||
pushTarget: GitPushTarget
|
||||
/** false when the PR has "Allow edits from maintainers" off; a push may be rejected. */
|
||||
maintainerCanModify?: boolean
|
||||
}
|
||||
|
||||
export async function getPullRequestPushTarget(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference
|
||||
): Promise<PullRequestPushTarget | null> {
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
const ghOptions = ghRepoExecOptions(context)
|
||||
const candidates = await resolvePullRequestLookupCandidates(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (candidates.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
let prStdout = ''
|
||||
let matchedRepository: GitHubApiRepository | null = null
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', `repos/${candidate.owner}/${candidate.repo}/pulls/${prNumber}`],
|
||||
{ ...ghOptions, ...githubHostExecOptions(candidate) }
|
||||
)
|
||||
prStdout = stdout
|
||||
matchedRepository = candidate
|
||||
break
|
||||
} catch (error) {
|
||||
// Why: origin is often the contributor fork while the PR belongs to upstream; probe all PR repos before giving up.
|
||||
if (isNotFoundGhError(error)) {
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (!prStdout || !matchedRepository) {
|
||||
return null
|
||||
}
|
||||
const origin = await getGitHubApiRepositoryForRemote(
|
||||
repoPath,
|
||||
'origin',
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
const pr = JSON.parse(prStdout) as {
|
||||
maintainer_can_modify?: boolean
|
||||
head?: {
|
||||
ref?: string
|
||||
repo?: {
|
||||
full_name?: string
|
||||
clone_url?: string
|
||||
ssh_url?: string
|
||||
owner?: { login?: string }
|
||||
name?: string
|
||||
} | null
|
||||
}
|
||||
}
|
||||
const headRepo = pr.head?.repo
|
||||
const branchName = pr.head?.ref?.trim()
|
||||
const owner = headRepo?.owner?.login?.trim()
|
||||
const repo = headRepo?.name?.trim() ?? headRepo?.full_name?.split('/')[1]?.trim()
|
||||
const cloneUrl = headRepo?.clone_url?.trim()
|
||||
const sshUrl = headRepo?.ssh_url?.trim()
|
||||
const maintainerCanModify =
|
||||
typeof pr.maintainer_can_modify === 'boolean' ? pr.maintainer_can_modify : undefined
|
||||
if (!owner || !repo || !branchName || !cloneUrl || !sshUrl) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
origin &&
|
||||
githubRepoIdentityKey(origin) ===
|
||||
githubRepoIdentityKey({ owner, repo, host: matchedRepository.host })
|
||||
) {
|
||||
return {
|
||||
pushTarget: { remoteName: 'origin', branchName },
|
||||
...(maintainerCanModify !== undefined ? { maintainerCanModify } : {})
|
||||
}
|
||||
}
|
||||
|
||||
let originUrl: string | null = null
|
||||
try {
|
||||
const rawOriginUrl = await getRemoteUrlForRepo(context, 'origin')
|
||||
originUrl = rawOriginUrl?.trim() || null
|
||||
} catch {
|
||||
originUrl = null
|
||||
}
|
||||
return {
|
||||
pushTarget: {
|
||||
remoteName: sanitizeRemoteName(owner, repo),
|
||||
branchName,
|
||||
remoteUrl: pickPushRemoteUrl({ originUrl, cloneUrl, sshUrl })
|
||||
},
|
||||
...(maintainerCanModify !== undefined ? { maintainerCanModify } : {})
|
||||
}
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export function isPositiveSafeInteger(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
|
||||
}
|
||||
|
||||
export function isGitObjectId(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value)
|
||||
}
|
||||
|
||||
export function isUsableRestStackMetadata(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
const stack = value as {
|
||||
number?: unknown
|
||||
position?: unknown
|
||||
size?: unknown
|
||||
base?: unknown
|
||||
}
|
||||
if (!stack.base || typeof stack.base !== 'object' || Array.isArray(stack.base)) {
|
||||
return false
|
||||
}
|
||||
const base = stack.base as { ref?: unknown; sha?: unknown }
|
||||
return (
|
||||
isPositiveSafeInteger(stack.number) &&
|
||||
isPositiveSafeInteger(stack.position) &&
|
||||
isPositiveSafeInteger(stack.size) &&
|
||||
stack.position <= stack.size &&
|
||||
typeof base.ref === 'string' &&
|
||||
base.ref.trim().length > 0 &&
|
||||
(base.sha === undefined || isGitObjectId(base.sha))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { gitExecFileAsync } from '../../gh-utils'
|
||||
import { getSshGitProvider, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../../../providers/ssh-git-dispatch'
|
||||
import { readLocalGitConfigSignature } from '../../local-git-config-signature'
|
||||
import { TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS, trackedUpstreamSnapshotCache, trackedUpstreamSnapshotInFlight, trackedUpstreamSnapshotGenerations, beginTrackedUpstreamSnapshotProbe, finishTrackedUpstreamSnapshotProbe, pruneTrackedUpstreamSnapshotCache, parseTrackedUpstreamBranch, getCacheableTrackedUpstreamSnapshot, canUseCachedTrackedUpstreamBranch, doesTrackedUpstreamCacheConfigSignatureMatch, getTrackedUpstreamBranchCacheKey, type TrackedUpstreamBranch, type TrackedUpstreamSnapshotProbeResult } from './tracked-upstream-cache'
|
||||
export async function getTrackedUpstreamBranch(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: { wslDistro?: string } = {}
|
||||
): Promise<TrackedUpstreamBranch | null> {
|
||||
const cacheKey = getTrackedUpstreamBranchCacheKey(repoPath, connectionId, localGitOptions)
|
||||
const now = Date.now()
|
||||
const cached = trackedUpstreamSnapshotCache.get(cacheKey)
|
||||
if (cached && cached.expiresAt > now) {
|
||||
const configSignatureMatches = await doesTrackedUpstreamCacheConfigSignatureMatch(
|
||||
cached,
|
||||
repoPath,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (
|
||||
configSignatureMatches &&
|
||||
cached.upstreamsByBranchName.has(branchName) &&
|
||||
canUseCachedTrackedUpstreamBranch(cached, branchName)
|
||||
) {
|
||||
return cached.upstreamsByBranchName.get(branchName) ?? null
|
||||
}
|
||||
trackedUpstreamSnapshotCache.delete(cacheKey)
|
||||
}
|
||||
if (cached) {
|
||||
trackedUpstreamSnapshotCache.delete(cacheKey)
|
||||
}
|
||||
|
||||
const inFlight = trackedUpstreamSnapshotInFlight.get(cacheKey)
|
||||
if (inFlight) {
|
||||
const result = await inFlight
|
||||
if (result.upstreamsByBranchName.has(branchName)) {
|
||||
return result.upstreamsByBranchName.get(branchName) ?? null
|
||||
}
|
||||
// Why: a concurrent snapshot may finish before this branch exists in git; re-probe instead of returning a synthetic null.
|
||||
const retryInFlight = trackedUpstreamSnapshotInFlight.get(cacheKey)
|
||||
if (retryInFlight) {
|
||||
const retryResult = await retryInFlight
|
||||
return retryResult.upstreamsByBranchName.get(branchName) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: PR polling asks about hundreds of branches at once; read all upstreams in one git process per repo/runtime, not one probe per branch.
|
||||
const probeGeneration = beginTrackedUpstreamSnapshotProbe(cacheKey)
|
||||
const probe = probeTrackedUpstreamSnapshot(repoPath, connectionId, localGitOptions)
|
||||
trackedUpstreamSnapshotInFlight.set(cacheKey, probe)
|
||||
try {
|
||||
const result = await probe
|
||||
if (result.cacheable && trackedUpstreamSnapshotGenerations.get(cacheKey) === probeGeneration) {
|
||||
trackedUpstreamSnapshotCache.set(cacheKey, {
|
||||
...(result.gitConfigSignature ? { gitConfigSignature: result.gitConfigSignature } : {}),
|
||||
upstreamsByBranchName: getCacheableTrackedUpstreamSnapshot(result.upstreamsByBranchName),
|
||||
expiresAt: Date.now() + TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS
|
||||
})
|
||||
pruneTrackedUpstreamSnapshotCache(Date.now())
|
||||
}
|
||||
if (trackedUpstreamSnapshotGenerations.get(cacheKey) !== probeGeneration) {
|
||||
const fresherCached = trackedUpstreamSnapshotCache.get(cacheKey)
|
||||
if (fresherCached?.upstreamsByBranchName.has(branchName)) {
|
||||
return fresherCached.upstreamsByBranchName.get(branchName) ?? null
|
||||
}
|
||||
}
|
||||
return result.upstreamsByBranchName.get(branchName) ?? null
|
||||
} finally {
|
||||
if (trackedUpstreamSnapshotInFlight.get(cacheKey) === probe) {
|
||||
trackedUpstreamSnapshotInFlight.delete(cacheKey)
|
||||
}
|
||||
finishTrackedUpstreamSnapshotProbe(cacheKey, probeGeneration)
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeTrackedUpstreamSnapshot(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: { wslDistro?: string } = {}
|
||||
): Promise<TrackedUpstreamSnapshotProbeResult> {
|
||||
const startingGitConfigSignature = await readLocalGitConfigSignature({
|
||||
repoPath,
|
||||
connectionId: connectionId ?? null,
|
||||
...localGitOptions
|
||||
})
|
||||
const { probeFailed, upstreamsByBranchName } = await probeTrackedUpstreamBranches(
|
||||
repoPath,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
const endingGitConfigSignature = await readLocalGitConfigSignature({
|
||||
repoPath,
|
||||
connectionId: connectionId ?? null,
|
||||
...localGitOptions
|
||||
})
|
||||
const isLocalHostRuntime = !connectionId && !localGitOptions.wslDistro
|
||||
const configSignatureChanged =
|
||||
isLocalHostRuntime && startingGitConfigSignature !== endingGitConfigSignature
|
||||
const gitConfigSignature =
|
||||
startingGitConfigSignature === endingGitConfigSignature ? endingGitConfigSignature : undefined
|
||||
return {
|
||||
// Why: don't cache an empty snapshot after a transient git failure, or every branch lookup re-probes on the next refresh tick.
|
||||
cacheable: !configSignatureChanged && !probeFailed,
|
||||
probeFailed,
|
||||
...(gitConfigSignature ? { gitConfigSignature } : {}),
|
||||
upstreamsByBranchName
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeTrackedUpstreamBranches(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: { wslDistro?: string } = {}
|
||||
): Promise<{
|
||||
probeFailed: boolean
|
||||
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
|
||||
}> {
|
||||
const args = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads']
|
||||
const provider = connectionId ? getSshGitProvider(connectionId) : null
|
||||
if (connectionId && !provider) {
|
||||
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
if (provider) {
|
||||
const result = await provider.exec(args, repoPath)
|
||||
return {
|
||||
probeFailed: false,
|
||||
upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await gitExecFileAsync(args, {
|
||||
cwd: repoPath,
|
||||
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
|
||||
})
|
||||
return {
|
||||
probeFailed: false,
|
||||
upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout)
|
||||
}
|
||||
} catch {
|
||||
return { probeFailed: true, upstreamsByBranchName: new Map() }
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTrackedUpstreamBranches(stdout: string): Map<string, TrackedUpstreamBranch | null> {
|
||||
const upstreamsByBranchName = new Map<string, TrackedUpstreamBranch | null>()
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
const [branchName, upstreamRef] = line.split('\0')
|
||||
const localBranchName = branchName?.replace(/^refs\/heads\//, '')
|
||||
if (!localBranchName) {
|
||||
continue
|
||||
}
|
||||
upstreamsByBranchName.set(localBranchName, parseTrackedUpstreamRef(upstreamRef ?? ''))
|
||||
}
|
||||
return upstreamsByBranchName
|
||||
}
|
||||
|
||||
export function parseTrackedUpstreamRef(upstreamRef: string): TrackedUpstreamBranch | null {
|
||||
const remoteRefPrefix = 'refs/remotes/'
|
||||
const normalizedRef = upstreamRef.trim()
|
||||
if (normalizedRef.startsWith(remoteRefPrefix)) {
|
||||
return parseTrackedUpstreamBranch(normalizedRef.slice(remoteRefPrefix.length))
|
||||
}
|
||||
if (normalizedRef.startsWith('refs/heads/')) {
|
||||
return null
|
||||
}
|
||||
return parseTrackedUpstreamBranch(normalizedRef)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { splitRemoteBranchName } from '../../../../shared/git-effective-upstream'
|
||||
import { type OwnerRepo } from '../../gh-utils'
|
||||
import { readLocalGitConfigSignature } from '../../local-git-config-signature'
|
||||
import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key'
|
||||
export type TrackedUpstreamBranch = {
|
||||
remoteName: string
|
||||
branchName: string
|
||||
}
|
||||
|
||||
export const TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS = 30_000
|
||||
|
||||
export const TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES = 512
|
||||
|
||||
export type TrackedUpstreamSnapshotCacheEntry = {
|
||||
expiresAt: number
|
||||
gitConfigSignature?: string
|
||||
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
|
||||
}
|
||||
|
||||
export type TrackedUpstreamSnapshotProbeResult = {
|
||||
cacheable: boolean
|
||||
gitConfigSignature?: string
|
||||
probeFailed: boolean
|
||||
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
|
||||
}
|
||||
|
||||
export const trackedUpstreamSnapshotCache = new Map<string, TrackedUpstreamSnapshotCacheEntry>()
|
||||
|
||||
export const trackedUpstreamSnapshotInFlight = new Map<
|
||||
string,
|
||||
Promise<TrackedUpstreamSnapshotProbeResult>
|
||||
>()
|
||||
|
||||
export const trackedUpstreamSnapshotGenerations = new Map<string, symbol>()
|
||||
|
||||
export function beginTrackedUpstreamSnapshotProbe(cacheKey: string): symbol {
|
||||
const generation = Symbol()
|
||||
trackedUpstreamSnapshotGenerations.set(cacheKey, generation)
|
||||
return generation
|
||||
}
|
||||
|
||||
export function finishTrackedUpstreamSnapshotProbe(cacheKey: string, generation: symbol): void {
|
||||
// Why: generations only guard an active probe; retaining completed keys leaks worktree/runtime identities past the snapshot TTL.
|
||||
if (trackedUpstreamSnapshotGenerations.get(cacheKey) === generation) {
|
||||
trackedUpstreamSnapshotGenerations.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneTrackedUpstreamSnapshotCache(now: number): void {
|
||||
for (const [cacheKey, cached] of trackedUpstreamSnapshotCache) {
|
||||
if (cached.expiresAt <= now) {
|
||||
trackedUpstreamSnapshotCache.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
// Why: workspace/runtime churn can create unbounded unique keys within one TTL window, so expiry sweeping alone isn't a memory bound.
|
||||
while (trackedUpstreamSnapshotCache.size > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = trackedUpstreamSnapshotCache.keys().next().value
|
||||
if (oldestKey === undefined) {
|
||||
break
|
||||
}
|
||||
trackedUpstreamSnapshotCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function _getTrackedUpstreamBranchCacheSizesForTests(): {
|
||||
snapshots: number
|
||||
inFlight: number
|
||||
generations: number
|
||||
} {
|
||||
return {
|
||||
snapshots: trackedUpstreamSnapshotCache.size,
|
||||
inFlight: trackedUpstreamSnapshotInFlight.size,
|
||||
generations: trackedUpstreamSnapshotGenerations.size
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetTrackedUpstreamBranchCacheForTests(): void {
|
||||
trackedUpstreamSnapshotCache.clear()
|
||||
trackedUpstreamSnapshotInFlight.clear()
|
||||
trackedUpstreamSnapshotGenerations.clear()
|
||||
}
|
||||
|
||||
export function parseTrackedUpstreamBranch(upstreamRef: string): TrackedUpstreamBranch | null {
|
||||
const parsed = splitRemoteBranchName(upstreamRef.trim())
|
||||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function shouldRetryTrackedUpstreamBranch(
|
||||
upstreamBranch: TrackedUpstreamBranch,
|
||||
branchName: string,
|
||||
upstreamHeadRepo: OwnerRepo,
|
||||
headRepo: OwnerRepo | null
|
||||
): boolean {
|
||||
if (upstreamBranch.branchName !== branchName) {
|
||||
return true
|
||||
}
|
||||
if (!headRepo) {
|
||||
return true
|
||||
}
|
||||
return githubRepoIdentityKey(upstreamHeadRepo) !== githubRepoIdentityKey(headRepo)
|
||||
}
|
||||
|
||||
export function getCacheableTrackedUpstreamSnapshot(
|
||||
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
|
||||
): Map<string, TrackedUpstreamBranch | null> {
|
||||
// Why: SSH/WSL can't cheaply inspect remote .git/config here; the short TTL bounds stale positives while refreshes share one scan.
|
||||
return upstreamsByBranchName
|
||||
}
|
||||
|
||||
export function canUseCachedTrackedUpstreamBranch(
|
||||
cached: TrackedUpstreamSnapshotCacheEntry,
|
||||
branchName: string
|
||||
): boolean {
|
||||
return cached.upstreamsByBranchName.has(branchName)
|
||||
}
|
||||
|
||||
export async function doesTrackedUpstreamCacheConfigSignatureMatch(
|
||||
cached: TrackedUpstreamSnapshotCacheEntry,
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: { wslDistro?: string } = {}
|
||||
): Promise<boolean> {
|
||||
if (!cached.gitConfigSignature) {
|
||||
return true
|
||||
}
|
||||
const currentSignature = await readLocalGitConfigSignature({
|
||||
repoPath,
|
||||
connectionId: connectionId ?? null,
|
||||
...localGitOptions
|
||||
})
|
||||
return currentSignature === cached.gitConfigSignature
|
||||
}
|
||||
|
||||
export function getTrackedUpstreamBranchCacheKey(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: { wslDistro?: string } = {}
|
||||
): string {
|
||||
const runtimeKey = connectionId
|
||||
? `ssh:${connectionId}`
|
||||
: `local:${localGitOptions.wslDistro ?? 'host'}`
|
||||
return [runtimeKey, repoPath].join('\0')
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { PRComment } from '../../../../shared/github/comment-types'
|
||||
export function mapReviewCommentResponse(
|
||||
data: {
|
||||
id?: number
|
||||
node_id?: string | null
|
||||
user: { login: string; avatar_url: string; type?: string } | null
|
||||
body?: string
|
||||
created_at?: string
|
||||
html_url?: string
|
||||
path?: string
|
||||
line?: number | null
|
||||
},
|
||||
body: string,
|
||||
path?: string,
|
||||
line?: number,
|
||||
startLine?: number,
|
||||
threadId?: string
|
||||
): PRComment {
|
||||
return {
|
||||
id: data.id ?? Date.now(),
|
||||
reactionSubjectId: data.node_id?.trim() || undefined,
|
||||
author: data.user?.login ?? 'You',
|
||||
authorAvatarUrl: data.user?.avatar_url ?? '',
|
||||
body: data.body ?? body,
|
||||
createdAt: data.created_at ?? new Date().toISOString(),
|
||||
url: data.html_url ?? '',
|
||||
isBot: data.user?.type === 'Bot',
|
||||
path: data.path ?? path,
|
||||
line: data.line ?? line,
|
||||
startLine,
|
||||
threadId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { PRMergeableState, PRReviewDecision } from '../../../../shared/github/pull-request-types'
|
||||
import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
|
||||
import { summarizeProviderChecks } from '../../../../shared/provider-check-summary'
|
||||
// Why: omit repoId — the main process only has the path; the renderer stamps repoId after IPC.
|
||||
export type MainWorkItem = Omit<GitHubWorkItem, 'repoId'>
|
||||
|
||||
export const WORK_ITEM_PR_LIST_JSON_FIELDS =
|
||||
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests'
|
||||
|
||||
// Requested reviewers stay in the list payload because Tasks renders that column on first paint.
|
||||
// Why: kept out of `gh pr list` — statusCheckRollup/reviewDecision/merge metadata fan out into expensive per-row GraphQL.
|
||||
// Requested reviewers stay in the list payload because Tasks renders that column on first paint.
|
||||
export const WORK_ITEM_PR_DETAIL_JSON_FIELDS =
|
||||
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,autoMergeRequest,maintainerCanModify'
|
||||
|
||||
/**
|
||||
* Derive author login + avatar_url together so GHE avatars render — the login-only
|
||||
* `{login}.png` URL 404s on GHE. REST uses `user.avatar_url`, gh/GraphQL `author.avatarUrl` (#8784).
|
||||
*/
|
||||
export function authorFieldsFromUnknown(
|
||||
item: Record<string, unknown>
|
||||
): Pick<MainWorkItem, 'author' | 'authorAvatarUrl'> {
|
||||
const user = userFromUnknown(item.user ?? item.author)
|
||||
if (!user) {
|
||||
return { author: null }
|
||||
}
|
||||
return {
|
||||
author: user.login,
|
||||
...(user.avatarUrl ? { authorAvatarUrl: user.avatarUrl } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function extractHeadOwnerLogin(item: Record<string, unknown>): string | null {
|
||||
// gh CLI `pr list --json headRepositoryOwner` shape: { login }
|
||||
if (typeof item.headRepositoryOwner === 'object' && item.headRepositoryOwner !== null) {
|
||||
const login = (item.headRepositoryOwner as { login?: unknown }).login
|
||||
if (typeof login === 'string' && login.trim()) {
|
||||
return login
|
||||
}
|
||||
}
|
||||
// REST API `pull_request` shape: head.repo.owner.login
|
||||
if (typeof item.head === 'object' && item.head !== null) {
|
||||
const head = item.head as { repo?: unknown; user?: unknown; label?: unknown }
|
||||
const repo = head.repo
|
||||
if (typeof repo === 'object' && repo !== null) {
|
||||
const owner = (repo as { owner?: unknown }).owner
|
||||
if (typeof owner === 'object' && owner !== null) {
|
||||
const login = (owner as { login?: unknown }).login
|
||||
if (typeof login === 'string' && login.trim()) {
|
||||
return login
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: a deleted/inaccessible fork returns head.repo = null but still has head.user/head.label.
|
||||
const user = head.user
|
||||
if (typeof user === 'object' && user !== null) {
|
||||
const login = (user as { login?: unknown }).login
|
||||
if (typeof login === 'string' && login.trim()) {
|
||||
return login
|
||||
}
|
||||
}
|
||||
if (typeof head.label === 'string') {
|
||||
const owner = head.label.split(':', 1)[0]?.trim()
|
||||
if (owner) {
|
||||
return owner
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function userFromUnknown(
|
||||
value: unknown
|
||||
): { login: string; name: string | null; avatarUrl: string } | null {
|
||||
if (typeof value === 'string') {
|
||||
const login = value.trim()
|
||||
return login ? { login, name: null, avatarUrl: '' } : null
|
||||
}
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return null
|
||||
}
|
||||
const raw = value as Record<string, unknown>
|
||||
const login = typeof raw.login === 'string' ? raw.login.trim() : ''
|
||||
if (!login) {
|
||||
return null
|
||||
}
|
||||
const databaseId = numberFromUnknown(raw.databaseId)
|
||||
return {
|
||||
login,
|
||||
name: typeof raw.name === 'string' ? raw.name : null,
|
||||
avatarUrl:
|
||||
typeof raw.avatarUrl === 'string'
|
||||
? raw.avatarUrl
|
||||
: typeof raw.avatar_url === 'string'
|
||||
? raw.avatar_url
|
||||
: databaseId !== undefined
|
||||
? `https://avatars.githubusercontent.com/u/${databaseId}?v=4`
|
||||
: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function usersFromUnknown(
|
||||
value: unknown
|
||||
): { login: string; name: string | null; avatarUrl: string }[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
const users: { login: string; name: string | null; avatarUrl: string }[] = []
|
||||
for (const entry of value) {
|
||||
const direct = userFromUnknown(entry)
|
||||
if (direct) {
|
||||
users.push(direct)
|
||||
continue
|
||||
}
|
||||
if (typeof entry === 'object' && entry !== null) {
|
||||
const raw = entry as Record<string, unknown>
|
||||
const nested = userFromUnknown(raw.requestedReviewer ?? raw.user ?? raw.author)
|
||||
if (nested) {
|
||||
users.push(nested)
|
||||
}
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
export function latestReviewsFromUnknown(value: unknown): NonNullable<GitHubWorkItem['latestReviews']> {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
const reviews: NonNullable<GitHubWorkItem['latestReviews']> = []
|
||||
for (const entry of value) {
|
||||
if (typeof entry !== 'object' || entry === null) {
|
||||
continue
|
||||
}
|
||||
const raw = entry as Record<string, unknown>
|
||||
const author = userFromUnknown(raw.author)
|
||||
if (!author) {
|
||||
continue
|
||||
}
|
||||
reviews.push({
|
||||
login: author.login,
|
||||
state: typeof raw.state === 'string' ? raw.state : null,
|
||||
avatarUrl: author.avatarUrl
|
||||
})
|
||||
}
|
||||
return reviews
|
||||
}
|
||||
|
||||
export function numberFromUnknown(value: unknown): number | undefined {
|
||||
const number = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(number) ? number : undefined
|
||||
}
|
||||
|
||||
export function normalizePRMergeable(value: unknown): PRMergeableState | undefined {
|
||||
const raw = typeof value === 'string' ? value.toUpperCase() : ''
|
||||
if (raw === 'MERGEABLE' || raw === 'CONFLICTING' || raw === 'UNKNOWN') {
|
||||
return raw
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'MERGEABLE' : 'CONFLICTING'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function normalizeReviewDecision(value: unknown): PRReviewDecision | null {
|
||||
return value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED'
|
||||
? value
|
||||
: null
|
||||
}
|
||||
|
||||
export function isAutoMergeEnabled(value: unknown): boolean {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
export function checkRollupEntries(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return []
|
||||
}
|
||||
const raw = value as Record<string, unknown>
|
||||
const nodes = (raw.contexts as { nodes?: unknown } | undefined)?.nodes
|
||||
return Array.isArray(nodes) ? nodes : []
|
||||
}
|
||||
|
||||
export function deriveWorkItemCheckSummary(value: unknown): GitHubWorkItem['checksSummary'] {
|
||||
return summarizeProviderChecks(
|
||||
checkRollupEntries(value).map((entry) => {
|
||||
if (typeof entry !== 'object' || entry === null) {
|
||||
return { status: '', conclusion: '' }
|
||||
}
|
||||
const raw = entry as Record<string, unknown>
|
||||
// Why: StatusContext reports `state` and carries no `status`; CheckRun reports both.
|
||||
return {
|
||||
status: String(raw.status ?? ''),
|
||||
conclusion: String(raw.conclusion ?? raw.state ?? '')
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { type OwnerRepo } from '../../gh-utils'
|
||||
import { authorFieldsFromUnknown, extractHeadOwnerLogin, usersFromUnknown, latestReviewsFromUnknown, numberFromUnknown, normalizePRMergeable, normalizeReviewDecision, isAutoMergeEnabled, deriveWorkItemCheckSummary, type MainWorkItem } from './work-item-field-coercion'
|
||||
export function mapIssueWorkItem(item: Record<string, unknown>): MainWorkItem {
|
||||
return {
|
||||
id: `issue:${String(item.number)}`,
|
||||
type: 'issue',
|
||||
number: Number(item.number),
|
||||
title: String(item.title ?? ''),
|
||||
state: String(item.state ?? 'open') === 'closed' ? 'closed' : 'open',
|
||||
url: String(item.html_url ?? item.url ?? ''),
|
||||
labels: Array.isArray(item.labels)
|
||||
? item.labels
|
||||
.map((label) =>
|
||||
typeof label === 'object' && label !== null && 'name' in label
|
||||
? String((label as { name?: unknown }).name ?? '')
|
||||
: ''
|
||||
)
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
updatedAt: String(item.updated_at ?? item.updatedAt ?? ''),
|
||||
...authorFieldsFromUnknown(item),
|
||||
...(item.assignees !== undefined ? { assignees: usersFromUnknown(item.assignees) } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function mapPullRequestWorkItem(
|
||||
item: Record<string, unknown>,
|
||||
baseOwnerRepo: OwnerRepo | null = null
|
||||
): MainWorkItem {
|
||||
// Why: fork PRs are disabled in the Start-from picker; compare head owner to the selected repo's owner.
|
||||
const headOwnerLogin = extractHeadOwnerLogin(item)
|
||||
// Why: leave isCrossRepository undefined when the head owner is unknown, rather than falsely claiming "not a fork".
|
||||
const isCrossRepository =
|
||||
headOwnerLogin !== null && baseOwnerRepo !== null
|
||||
? headOwnerLogin !== baseOwnerRepo.owner
|
||||
: null
|
||||
const state = String(item.state ?? '').toLowerCase()
|
||||
const additions = numberFromUnknown(item.additions)
|
||||
const deletions = numberFromUnknown(item.deletions)
|
||||
const changedFiles = numberFromUnknown(
|
||||
item.changedFiles ??
|
||||
item.changed_files ??
|
||||
(item.files as { totalCount?: unknown } | undefined)?.totalCount
|
||||
)
|
||||
const mergeable = normalizePRMergeable(item.mergeable)
|
||||
const headSha =
|
||||
typeof item.headRefOid === 'string'
|
||||
? item.headRefOid
|
||||
: typeof item.head === 'object' && item.head !== null
|
||||
? typeof (item.head as { sha?: unknown }).sha === 'string'
|
||||
? (item.head as { sha: string }).sha
|
||||
: undefined
|
||||
: undefined
|
||||
return {
|
||||
id: `pr:${String(item.number)}`,
|
||||
type: 'pr',
|
||||
number: Number(item.number),
|
||||
title: String(item.title ?? ''),
|
||||
state:
|
||||
state === 'merged' || item.merged_at || item.mergedAt
|
||||
? 'merged'
|
||||
: state === 'closed'
|
||||
? 'closed'
|
||||
: item.isDraft || item.draft
|
||||
? 'draft'
|
||||
: 'open',
|
||||
url: String(item.html_url ?? item.url ?? ''),
|
||||
labels: Array.isArray(item.labels)
|
||||
? item.labels
|
||||
.map((label) =>
|
||||
typeof label === 'object' && label !== null && 'name' in label
|
||||
? String((label as { name?: unknown }).name ?? '')
|
||||
: ''
|
||||
)
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
updatedAt: String(item.updated_at ?? item.updatedAt ?? ''),
|
||||
...authorFieldsFromUnknown(item),
|
||||
branchName:
|
||||
typeof item.head === 'object' && item.head !== null && 'ref' in item.head
|
||||
? String((item.head as { ref?: unknown }).ref ?? '')
|
||||
: String(item.headRefName ?? ''),
|
||||
baseRefName:
|
||||
typeof item.base === 'object' && item.base !== null && 'ref' in item.base
|
||||
? String((item.base as { ref?: unknown }).ref ?? '')
|
||||
: String(item.baseRefName ?? ''),
|
||||
...(headSha ? { headSha } : {}),
|
||||
...(baseOwnerRepo
|
||||
? {
|
||||
prRepo: {
|
||||
owner: baseOwnerRepo.owner,
|
||||
repo: baseOwnerRepo.repo,
|
||||
...(baseOwnerRepo.host ? { host: baseOwnerRepo.host } : {})
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(additions !== undefined ? { additions } : {}),
|
||||
...(deletions !== undefined ? { deletions } : {}),
|
||||
...(changedFiles !== undefined ? { changedFiles } : {}),
|
||||
...('reviewDecision' in item
|
||||
? { reviewDecision: normalizeReviewDecision(item.reviewDecision) }
|
||||
: {}),
|
||||
...(item.reviewRequests !== undefined || item.requested_reviewers !== undefined
|
||||
? { reviewRequests: usersFromUnknown(item.reviewRequests ?? item.requested_reviewers) }
|
||||
: {}),
|
||||
...(item.latestReviews !== undefined
|
||||
? { latestReviews: latestReviewsFromUnknown(item.latestReviews) }
|
||||
: {}),
|
||||
...(item.assignees !== undefined ? { assignees: usersFromUnknown(item.assignees) } : {}),
|
||||
...(item.statusCheckRollup !== undefined
|
||||
? { checksSummary: deriveWorkItemCheckSummary(item.statusCheckRollup) }
|
||||
: {}),
|
||||
...(mergeable ? { mergeable } : {}),
|
||||
...('autoMergeRequest' in item
|
||||
? { autoMergeEnabled: isAutoMergeEnabled(item.autoMergeRequest) }
|
||||
: {}),
|
||||
...('mergeStateStatus' in item
|
||||
? {
|
||||
mergeStateStatus: typeof item.mergeStateStatus === 'string' ? item.mergeStateStatus : null
|
||||
}
|
||||
: {}),
|
||||
...(typeof item.maintainerCanModify === 'boolean'
|
||||
? { maintainerCanModify: item.maintainerCanModify }
|
||||
: {}),
|
||||
...(isCrossRepository !== null ? { isCrossRepository } : {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { PRConflictSummary } from '../../../../shared/github/pull-request-types'
|
||||
import { getPRConflictSummary } from '../../conflict-summary'
|
||||
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { mergeGitHubPRStack } from '../../github-pr-stack'
|
||||
import { githubPRStackExecutionScope, type GhExecOptions } from './../github-exec-scope'
|
||||
import { detectRepositoryMergeMetadata } from './../detect/repository-merge-metadata'
|
||||
import type { PullRequestLookupData } from './../lookup/pull-request-lookup-data'
|
||||
import { getRestPRByNumber, getPRByNumber } from './../lookup/pr-number-lookup'
|
||||
import { STACK_METADATA_UNAVAILABLE_ERROR } from './../lookup/pr-stack-summary-cache'
|
||||
/**
|
||||
* Merge a PR by number using gh CLI.
|
||||
* method: 'merge' | 'squash' | 'rebase' (default: 'squash')
|
||||
*/
|
||||
export async function mergePR(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
method: 'merge' | 'squash' | 'rebase' = 'squash',
|
||||
connectionId?: string | null,
|
||||
prRepo?: GitHubApiRepository | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
prRepo,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
|
||||
}
|
||||
await acquire()
|
||||
let concurrencySlotHeld = true
|
||||
try {
|
||||
let restData: PullRequestLookupData
|
||||
try {
|
||||
restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions, {
|
||||
requireUsableStackMetadata: true
|
||||
})
|
||||
} catch (err) {
|
||||
const diagnostic =
|
||||
err instanceof SyntaxError
|
||||
? 'invalid JSON response'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
console.warn(
|
||||
`mergePR stack metadata probe failed for ${ownerRepo.owner}/${ownerRepo.repo}#${String(prNumber)}:`,
|
||||
diagnostic
|
||||
)
|
||||
return { ok: false, error: STACK_METADATA_UNAVAILABLE_ERROR }
|
||||
}
|
||||
if (restData.stack) {
|
||||
const mergeMetadata = await detectRepositoryMergeMetadata(
|
||||
ownerRepo,
|
||||
restData.stack.baseRefName,
|
||||
ghOptions,
|
||||
githubPRStackExecutionScope(connectionId, localGitOptions)
|
||||
)
|
||||
release()
|
||||
concurrencySlotHeld = false
|
||||
return await mergeGitHubPRStack({
|
||||
repository: ownerRepo,
|
||||
prNumber,
|
||||
method,
|
||||
mergeAction: mergeMetadata.mergeQueueRequired === true ? 'merge_queue' : 'direct_merge',
|
||||
headSha: restData.headRefOid,
|
||||
ghOptions
|
||||
})
|
||||
}
|
||||
const mergeBlocker = await getPRMergeBlocker(
|
||||
repoPath,
|
||||
prNumber,
|
||||
ownerRepo,
|
||||
ghOptions,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (mergeBlocker) {
|
||||
return { ok: false, error: mergeBlocker }
|
||||
}
|
||||
|
||||
// Don't use --delete-branch: it deletes the local branch, which fails while the worktree is checked out on it.
|
||||
const args = ['pr', 'merge', String(prNumber), `--${method}`]
|
||||
if (ownerRepo) {
|
||||
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
|
||||
}
|
||||
await ghExecFileAsync(args, {
|
||||
...ghOptions,
|
||||
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
|
||||
return { ok: false, error: message }
|
||||
} finally {
|
||||
if (concurrencySlotHeld) {
|
||||
release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPRMergeBlocker(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
ownerRepo: GitHubApiRepository | null,
|
||||
ghOptions: GhExecOptions,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<string | null> {
|
||||
if (!ownerRepo) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const pr = await getPRByNumber(
|
||||
ownerRepo,
|
||||
prNumber,
|
||||
ghOptions,
|
||||
githubPRStackExecutionScope(connectionId, localGitOptions)
|
||||
)
|
||||
if (!pr) {
|
||||
return null
|
||||
}
|
||||
if (pr.reviewDecision === 'REVIEW_REQUIRED') {
|
||||
return 'This pull request requires review approval before it can be merged.'
|
||||
}
|
||||
if (pr.reviewDecision === 'CHANGES_REQUESTED') {
|
||||
return 'This pull request has requested changes and cannot be merged yet.'
|
||||
}
|
||||
if (pr.mergeQueueRequired === true) {
|
||||
return 'This pull request must be merged through GitHub merge queue. Use Merge when ready instead.'
|
||||
}
|
||||
// Why: conflict summaries shell out to local git; skip for SSH repos until that helper routes through the SSH provider.
|
||||
if (
|
||||
connectionId ||
|
||||
pr.mergeable !== 'CONFLICTING' ||
|
||||
!pr.baseRefName ||
|
||||
!pr.baseRefOid ||
|
||||
!pr.headRefOid
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const summary = await getPRConflictSummary(
|
||||
repoPath,
|
||||
pr.baseRefName,
|
||||
pr.baseRefOid,
|
||||
pr.headRefOid,
|
||||
localGitOptions
|
||||
)
|
||||
return formatMergeConflictBlocker(pr.baseRefName, summary)
|
||||
} catch {
|
||||
// Why: conflict preflight should improve stale UI diagnostics, not block merge on a transient lookup failure.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMergeConflictBlocker(
|
||||
baseRefName: string,
|
||||
summary: PRConflictSummary | undefined
|
||||
): string {
|
||||
const heading = 'This pull request has merge conflicts and cannot be merged yet.'
|
||||
if (!summary || summary.files.length === 0) {
|
||||
return `${heading}\nUpdate the branch with ${baseRefName} and resolve the conflicts before merging.`
|
||||
}
|
||||
|
||||
const files = summary.files.map((file) => `- ${file}`).join('\n')
|
||||
const behind = `${summary.commitsBehind} commit${summary.commitsBehind === 1 ? '' : 's'} behind ${baseRefName}`
|
||||
return `${heading}\n${behind} (base commit: ${summary.baseCommit}).\n\nConflicting files:\n${files}`
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { GitHubPRMergeMethod } from '../../../../shared/github/pull-request-types'
|
||||
import { ghExecFileAsync, acquire, release, classifyGhError, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import type { GhExecOptions } from './../github-exec-scope'
|
||||
import { detectRepositoryMergeMetadata } from './../detect/repository-merge-metadata'
|
||||
import { getRestPRByNumber } from './../lookup/pr-number-lookup'
|
||||
export const PR_AUTO_MERGE_IDENTITY_JSON_FIELDS = 'id,headRefOid,baseRefName'
|
||||
|
||||
export const GITHUB_AUTO_MERGE_METHODS: Record<GitHubPRMergeMethod, 'MERGE' | 'SQUASH' | 'REBASE'> = {
|
||||
merge: 'MERGE',
|
||||
squash: 'SQUASH',
|
||||
rebase: 'REBASE'
|
||||
}
|
||||
|
||||
// Why: GitHub rejects auto-merge on an already-mergeable PR ("clean status"); surface an actionable message instead of the raw error.
|
||||
export function classifySetAutoMergeError(message: string): string {
|
||||
if (/in clean status/i.test(message)) {
|
||||
return 'This pull request can already be merged. Use Merge instead of auto-merge.'
|
||||
}
|
||||
return classifyGhError(message).message
|
||||
}
|
||||
|
||||
export type PRAutoMergeIdentity = {
|
||||
id?: string
|
||||
headRefOid?: string
|
||||
baseRefName?: string
|
||||
}
|
||||
|
||||
export async function getPRAutoMergeIdentity(
|
||||
prNumber: number,
|
||||
ownerRepo: GitHubApiRepository | null,
|
||||
ghOptions: GhExecOptions
|
||||
): Promise<PRAutoMergeIdentity | null> {
|
||||
const args = ['pr', 'view', String(prNumber), '--json', PR_AUTO_MERGE_IDENTITY_JSON_FIELDS]
|
||||
if (ownerRepo) {
|
||||
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
|
||||
}
|
||||
const { stdout } = await ghExecFileAsync(args, ghOptions)
|
||||
const data = JSON.parse(stdout) as PRAutoMergeIdentity
|
||||
return {
|
||||
id: typeof data.id === 'string' ? data.id : undefined,
|
||||
headRefOid: typeof data.headRefOid === 'string' ? data.headRefOid : undefined,
|
||||
baseRefName: typeof data.baseRefName === 'string' ? data.baseRefName : undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPRAutoMergeCommand(
|
||||
prNumber: number,
|
||||
method: GitHubPRMergeMethod,
|
||||
ownerRepo: GitHubApiRepository | null,
|
||||
ghOptions: GhExecOptions
|
||||
): Promise<void> {
|
||||
const args = ['pr', 'merge', String(prNumber), '--auto', `--${method}`]
|
||||
if (ownerRepo) {
|
||||
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
|
||||
}
|
||||
await ghExecFileAsync(args, {
|
||||
...ghOptions,
|
||||
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
|
||||
})
|
||||
}
|
||||
|
||||
export async function shouldUseMergeQueueAutoMerge(
|
||||
pr: PRAutoMergeIdentity,
|
||||
ownerRepo: GitHubApiRepository | null,
|
||||
ghOptions: GhExecOptions
|
||||
): Promise<boolean> {
|
||||
if (!ownerRepo || !pr.baseRefName) {
|
||||
return false
|
||||
}
|
||||
const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, pr.baseRefName, ghOptions)
|
||||
return mergeMetadata.mergeQueueRequired === true
|
||||
}
|
||||
|
||||
export async function enablePRAutoMerge(
|
||||
prNumber: number,
|
||||
method: GitHubPRMergeMethod,
|
||||
ownerRepo: GitHubApiRepository | null,
|
||||
ghOptions: GhExecOptions
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
if (ownerRepo) {
|
||||
try {
|
||||
const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions)
|
||||
if (restData.stack) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'GitHub does not support auto-merge for stacked pull requests.'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// GitHub remains authoritative when stack metadata cannot be read.
|
||||
}
|
||||
}
|
||||
const pr = await getPRAutoMergeIdentity(prNumber, ownerRepo, ghOptions)
|
||||
if (!pr?.id) {
|
||||
return { ok: false, error: 'Could not resolve GitHub pull request ID' }
|
||||
}
|
||||
const useMergeQueue = await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions)
|
||||
if (useMergeQueue) {
|
||||
await runPRAutoMergeCommand(prNumber, method, ownerRepo, ghOptions)
|
||||
return { ok: true }
|
||||
}
|
||||
const query = `mutation($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!, $expectedHeadOid: GitObjectID) {
|
||||
enablePullRequestAutoMerge(input: {
|
||||
pullRequestId: $pullRequestId,
|
||||
mergeMethod: $mergeMethod,
|
||||
expectedHeadOid: $expectedHeadOid
|
||||
}) {
|
||||
pullRequest { id }
|
||||
}
|
||||
}`
|
||||
const args = [
|
||||
'api',
|
||||
'graphql',
|
||||
'-f',
|
||||
`query=${query}`,
|
||||
'-f',
|
||||
`pullRequestId=${pr.id}`,
|
||||
'-f',
|
||||
`mergeMethod=${GITHUB_AUTO_MERGE_METHODS[method]}`
|
||||
]
|
||||
if (pr.headRefOid) {
|
||||
args.push('-f', `expectedHeadOid=${pr.headRefOid}`)
|
||||
}
|
||||
// Why: `gh pr merge --auto` can merge immediately; this mutation only creates the auto-merge request, letting branch requirements gate it.
|
||||
await ghExecFileAsync(args, {
|
||||
...ghOptions,
|
||||
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
|
||||
})
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export async function setPRAutoMerge(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
enabled: boolean,
|
||||
method: GitHubPRMergeMethod = 'squash',
|
||||
connectionId?: string | null,
|
||||
prRepo?: GitHubApiRepository | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
prRepo,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
if (enabled) {
|
||||
return await enablePRAutoMerge(prNumber, method, ownerRepo, ghOptions)
|
||||
}
|
||||
const args = ['pr', 'merge', String(prNumber), '--disable-auto']
|
||||
if (ownerRepo) {
|
||||
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
|
||||
}
|
||||
await ghExecFileAsync(args, {
|
||||
...ghOptions,
|
||||
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
|
||||
return { ok: false, error: classifySetAutoMergeError(message) }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { IssueSourcePreference } from '../../../shared/repo-types'
|
||||
import { type LocalGitExecOptions } from '../gh-utils'
|
||||
import { getOriginGitHubApiRepository, resolveGitHubApiRepositoryCandidates, type GitHubApiRepository } from '../github-api-repository'
|
||||
// resolvePrWorkItemSource list semantics.
|
||||
export async function resolvePullRequestLookupCandidates(
|
||||
repoPath: string,
|
||||
preference: IssueSourcePreference | undefined,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitHubApiRepository[]> {
|
||||
if (preference === 'origin') {
|
||||
const origin = await getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions)
|
||||
return origin ? [origin] : []
|
||||
}
|
||||
return (await resolveGitHubApiRepositoryCandidates(repoPath, connectionId, localGitOptions))
|
||||
.candidates
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { GitHubReactionContent } from '../../../../shared/github/comment-types'
|
||||
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { toGraphQLReactionContent } from '../../comment-reactions'
|
||||
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit'
|
||||
export async function setPRCommentReaction(
|
||||
repoPath: string,
|
||||
reactionSubjectId: string,
|
||||
content: GitHubReactionContent,
|
||||
reacted: boolean,
|
||||
connectionId?: string | null,
|
||||
prRepo?: GitHubApiRepository | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<boolean> {
|
||||
const mutation = reacted ? 'addReaction' : 'removeReaction'
|
||||
const query = `mutation($subjectId: ID!, $content: ReactionContent!) {
|
||||
${mutation}(input: { subjectId: $subjectId, content: $content }) {
|
||||
subject { id }
|
||||
}
|
||||
}`
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
prRepo,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return false
|
||||
}
|
||||
const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
|
||||
if (guard.blocked) {
|
||||
console.warn(
|
||||
`${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})`
|
||||
)
|
||||
return false
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
|
||||
await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'graphql',
|
||||
'-f',
|
||||
`query=${query}`,
|
||||
'-f',
|
||||
`subjectId=${reactionSubjectId}`,
|
||||
'-f',
|
||||
`content=${toGraphQLReactionContent(content)}`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn(`${mutation} failed:`, err)
|
||||
return false
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ghExecFileAsync, acquire, release, classifyGhError, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
|
||||
/**
|
||||
* Update a PR's title.
|
||||
*/
|
||||
export async function updatePRTitle(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
title: string,
|
||||
connectionId?: string | null,
|
||||
prRepo?: GitHubApiRepository | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<boolean> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
prRepo,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return false
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
const args = ['pr', 'edit', String(prNumber), '--title', title]
|
||||
if (ownerRepo) {
|
||||
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
|
||||
}
|
||||
await ghExecFileAsync(args, {
|
||||
...ghOptions
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('updatePRTitle failed:', err)
|
||||
return false
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePRDetails(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
updates: { title?: string; body?: string },
|
||||
connectionId?: string | null,
|
||||
prRepo?: GitHubApiRepository | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
prRepo,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
|
||||
}
|
||||
|
||||
const fields: string[] = []
|
||||
if (updates.title !== undefined) {
|
||||
const title = updates.title.trim()
|
||||
if (!title) {
|
||||
return { ok: false, error: 'Title is required' }
|
||||
}
|
||||
fields.push(`title=${title}`)
|
||||
}
|
||||
if (updates.body !== undefined) {
|
||||
fields.push(`body=${updates.body}`)
|
||||
}
|
||||
if (fields.length === 0) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'-X',
|
||||
'PATCH',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`,
|
||||
...fields.flatMap((field) => ['--raw-field', field])
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
|
||||
return { ok: false, error: classifyGhError(message).message }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
|
||||
/**
|
||||
* Mark or unmark a PR file as viewed via GitHub's GraphQL API.
|
||||
*/
|
||||
export async function setPRFileViewed(args: {
|
||||
repoPath: string
|
||||
connectionId?: string | null
|
||||
localGitOptions?: LocalGitExecOptions
|
||||
prRepo?: GitHubApiRepository | null
|
||||
pullRequestId: string
|
||||
path: string
|
||||
viewed: boolean
|
||||
}): Promise<boolean> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
args.repoPath,
|
||||
args.prRepo,
|
||||
args.connectionId,
|
||||
args.localGitOptions
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return false
|
||||
}
|
||||
const mutation = args.viewed ? 'markFileAsViewed' : 'unmarkFileAsViewed'
|
||||
const query = `mutation($pullRequestId: ID!, $path: String!) {
|
||||
${mutation}(input: { pullRequestId: $pullRequestId, path: $path }) {
|
||||
pullRequest { id }
|
||||
}
|
||||
}`
|
||||
await acquire()
|
||||
try {
|
||||
await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'graphql',
|
||||
'-f',
|
||||
`query=${query}`,
|
||||
'-f',
|
||||
`pullRequestId=${args.pullRequestId}`,
|
||||
'-f',
|
||||
`path=${args.path}`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn(`${mutation} failed:`, err)
|
||||
return false
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
|
||||
export async function requestPRReviewers(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
reviewers: string[],
|
||||
connectionId?: string | null,
|
||||
prRepo?: GitHubApiRepository | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean)
|
||||
if (logins.length === 0) {
|
||||
return { ok: false, error: 'Enter at least one reviewer' }
|
||||
}
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
prRepo,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
const args = ['pr', 'edit', String(prNumber), '--add-reviewer', logins.join(',')]
|
||||
if (ownerRepo) {
|
||||
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
|
||||
}
|
||||
await ghExecFileAsync(args, {
|
||||
...ghOptions,
|
||||
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
|
||||
return { ok: false, error: message }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function removePRReviewers(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
reviewers: string[],
|
||||
connectionId?: string | null,
|
||||
prRepo?: GitHubApiRepository | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const logins = reviewers.map((reviewer) => reviewer.trim()).filter(Boolean)
|
||||
if (logins.length === 0) {
|
||||
return { ok: false, error: 'Enter at least one reviewer' }
|
||||
}
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
prRepo,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
const args = ['pr', 'edit', String(prNumber), '--remove-reviewer', logins.join(',')]
|
||||
if (ownerRepo) {
|
||||
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
|
||||
}
|
||||
await ghExecFileAsync(args, {
|
||||
...ghOptions,
|
||||
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
|
||||
return { ok: false, error: message }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { GitHubPullRequestStateUpdate } from '../../../../shared/issue-mutation-types'
|
||||
import { ghExecFileAsync, acquire, release, classifyGhError, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
|
||||
export async function updatePRState(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
updates: GitHubPullRequestStateUpdate,
|
||||
connectionId?: string | null,
|
||||
prRepo?: GitHubApiRepository | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
prRepo,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const cmd = updates.state === 'closed' ? 'close' : 'reopen'
|
||||
// Why: gh's PR commands use GitHub's supported reopen flow; REST state PATCH can 422 on reopen.
|
||||
await ghExecFileAsync(
|
||||
['pr', cmd, String(prNumber), '--repo', `${ownerRepo.owner}/${ownerRepo.repo}`],
|
||||
{
|
||||
...ghOptions
|
||||
}
|
||||
)
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
|
||||
return { ok: false, error: classifyGhError(message).message }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit'
|
||||
/**
|
||||
* Resolve or unresolve a PR review thread via GraphQL.
|
||||
*/
|
||||
export async function resolveReviewThread(
|
||||
repoPath: string,
|
||||
threadId: string,
|
||||
resolve: boolean,
|
||||
connectionId?: string | null,
|
||||
prRepo?: GitHubApiRepository | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<boolean> {
|
||||
const mutation = resolve ? 'resolveReviewThread' : 'unresolveReviewThread'
|
||||
const query = `mutation($threadId: ID!) { ${mutation}(input: { threadId: $threadId }) { thread { isResolved } } }`
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
prRepo,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return false
|
||||
}
|
||||
const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
|
||||
if (guard.blocked) {
|
||||
console.warn(
|
||||
`${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})`
|
||||
)
|
||||
return false
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
|
||||
await ghExecFileAsync(
|
||||
['api', 'graphql', '-f', `query=${query}`, '-f', `threadId=${threadId}`],
|
||||
ghOptions
|
||||
)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn(`${mutation} failed:`, err)
|
||||
return false
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user