Split github client (#15214)

* refactor(github-client): reorganize client into lifecycle folders

* refactor(github-client): extract PR refresh data and outcome assembly

Separate the derived data calculation and outcome assembly logic from
branch-lookup-resolution into dedicated modules for better separation of
concerns. Modernize type import syntax and format exports consistently.

* refactor(github-client): improve error handling and resilience

Defensive GraphQL parsing prevents partial responses from breaking REST fallbacks.
Cache failures now use shorter TTLs for faster recovery. PR operations have
dedicated error classification. GraphQL mutations track rate limit usage to prevent
quota exhaustion. Data validation improved to reject spurious values.

* Extract check rerun error classification with operation context

Create classifyRerunChecksError() to provide operation-specific error
messages when check reruns fail. This replaces generic GitHub error
copy with context appropriate to what the user attempted (rerun
checks). Follows the pattern of classifyListPrsError and improves
error handling by delegating extraction to extractExecError.

* Make check-rerun not-found error message resource-neutral

Error handling for failed check reruns now covers both workflow-run
reruns and standalone check-run rerequests. Tests verify the neutral
message works for both scenarios.
This commit is contained in:
Jinjing
2026-08-17 21:18:44 -07:00
committed by GitHub
parent 604169f4af
commit 63dbf12d14
62 changed files with 6314 additions and 5601 deletions
-1
View File
@@ -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
+68
View File
@@ -568,6 +568,74 @@ describe('getPRChecks', () => {
}
)
})
it('reports a resource-neutral not-found error when a workflow-run rerun 404s', async () => {
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce(
graphQLChecksResponse({
contexts: [
graphQLCheckRun({
name: 'lint',
conclusion: 'FAILURE',
detailsUrl: 'https://github.com/acme/widgets/actions/runs/77/job/88',
workflowRunId: 77
})
]
})
)
.mockRejectedValueOnce(
Object.assign(new Error('Command failed: gh api'), {
stderr: 'gh: HTTP 404 Not Found (repos/acme/widgets/actions/runs/77/rerun-failed-jobs)',
stdout: ''
})
)
const result = await rerunPRChecks('/repo-root', 42, { failedOnly: true })
expect(result).toEqual({
ok: false,
error: 'GitHub resource to rerun was not found — it may have expired or been deleted.'
})
})
it('reports a resource-neutral not-found error when a standalone check-run rerequest 404s', async () => {
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce(
graphQLChecksResponse({
contexts: [
{
__typename: 'CheckRun',
databaseId: 88,
name: 'external-ci',
status: 'COMPLETED',
conclusion: 'FAILURE',
detailsUrl: 'https://ci.example.com/builds/88',
url: 'https://ci.example.com/builds/88',
checkSuite: { databaseId: 1000, workflowRun: null }
}
]
})
)
.mockRejectedValueOnce(
Object.assign(new Error('Command failed: gh api'), {
stderr: 'gh: HTTP 404 Not Found (repos/acme/widgets/check-runs/88/rerequest)',
stdout: ''
})
)
const result = await rerunPRChecks('/repo-root', 42, { failedOnly: true })
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['api', '-X', 'POST', 'repos/acme/widgets/check-runs/88/rerequest'],
expect.objectContaining({ cwd: '/repo-root' })
)
expect(result).toEqual({
ok: false,
error: 'GitHub resource to rerun was not found — it may have expired or been deleted.'
})
})
it('routes local WSL check retrieval and reruns through the selected distro', async () => {
const localGitOptions = { wslDistro: 'Ubuntu' }
+38 -5591
View File
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,26 @@
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,85 @@
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: only the tail is kept, but the whole log buffers first — the default 10MiB cap drops long CI logs.
// Still far below the V8 string ceiling that DEFAULT_GIT_MAX_BUFFER guards against.
export const PR_CHECK_LOG_MAX_BUFFER = 64 * 1024 * 1024
// 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, maxBuffer: PR_CHECK_LOG_MAX_BUFFER }
)
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,128 @@
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,233 @@
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,186 @@
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,105 @@
import type { GitHubRerunPRChecksResult } from '../../../../shared/github/check-types'
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
// Why: pure error helpers come from their own modules so tests that mock gh-utils still classify for real.
import { extractExecError } from '../../../git/exec-error'
import { classifyRerunChecksError } from '../../gh-error-classification'
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 { stderr } = extractExecError(err)
const classified = classifyRerunChecksError(stderr).message
// Why: these POSTs are not idempotent — say how many reruns already started so a retry isn't blind.
return {
ok: false,
error:
count > 0
? `${classified} (${count} rerun${count === 1 ? '' : 's'} already started)`
: classified
}
} finally {
release()
}
}
@@ -0,0 +1,121 @@
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)
const data = JSON.parse(stdout) as Parameters<typeof mapReviewCommentResponse>[0]
// Why: mapReviewCommentResponse substitutes Date.now() for a missing id, which later replies/reactions would target.
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, 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,162 @@
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,83 @@
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,38 @@
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,
executionScope?: string
): 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,
executionScope
)
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,121 @@
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: string | undefined = 'default'
): Promise<GitHubRepositoryMergeMetadata> {
const cacheKey = `${executionScope ?? 'default'}\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 } : {})
}
// Why: a payload without `repository` is all-unknown; keep it on the short TTL so it retries once the condition clears.
cacheRepositoryMergeMetadata(
cacheKey,
value,
repository ? MERGE_QUEUE_CACHE_TTL_MS : MERGE_QUEUE_UNKNOWN_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,279 @@
import type { PRComment } from '../../../../shared/github/comment-types'
import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items'
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from '../../comment-reactions'
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit'
import { assertRateLimitBudget } from './../lookup/pr-lookup-rate-limit'
import { REVIEW_THREADS_QUERY } from './pr-review-threads-query'
/**
* Get all comments on a PR — both top-level conversation comments and inline
* review comments (including suggestions). Uses GraphQL for review threads
* to get resolution status, REST for issue-level comments.
*/
export async function getPRComments(
repoPath: string,
prNumber: number,
options?: { noCache?: boolean; prRepo?: GitHubApiRepository | null },
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<PRComment[]> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
options?.prRepo,
connectionId,
localGitOptions
)
if (connectionId && !ownerRepo) {
throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
}
if (ownerRepo) {
await assertRateLimitBudget('core', ownerRepo, ghOptions)
}
await acquire()
try {
if (ownerRepo) {
// Why: --cache 60s saves rate-limit budget on normal loads; explicit refresh skips it for fresh data.
const cacheArgs = options?.noCache ? [] : ['--cache', '60s']
const base = `repos/${ownerRepo.owner}/${ownerRepo.repo}`
// Why: allSettled so one failing endpoint doesn't blank out all comments; failed sources contribute zero.
const reviewThreadsGuard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions)
let reviewThreadsFetch: Promise<{ stdout: string; stderr: string } | null>
if (reviewThreadsGuard.blocked) {
reviewThreadsFetch = Promise.resolve(null)
} else {
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
reviewThreadsFetch = ghExecFileAsync(
[
'api',
'graphql',
'-f',
`query=${REVIEW_THREADS_QUERY}`,
'-f',
`owner=${ownerRepo.owner}`,
'-f',
`repo=${ownerRepo.repo}`,
'-F',
`pr=${prNumber}`
],
ghOptions
)
}
const [issueResult, threadsResult, reviewsResult] = await Promise.allSettled([
ghExecFileAsync(
['api', ...cacheArgs, `${base}/issues/${prNumber}/comments?per_page=100`],
ghOptions
),
reviewThreadsFetch,
// Why: review summaries (approve/request-changes/general) live under pulls/{n}/reviews, not issue comments or threads.
ghExecFileAsync(
['api', ...cacheArgs, `${base}/pulls/${prNumber}/reviews?per_page=100`],
ghOptions
)
])
noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions)
// Parse issue comments (REST)
type RESTComment = {
id: number
user: { login: string; avatar_url: string; type?: string } | null
body: string
created_at: string
html_url: string
}
let issueComments: PRComment[] = []
if (issueResult.status === 'fulfilled') {
issueComments = (JSON.parse(issueResult.value.stdout) as RESTComment[]).map(
(c): PRComment => ({
id: c.id,
author: c.user?.login ?? 'ghost',
authorAvatarUrl: c.user?.avatar_url ?? '',
body: c.body ?? '',
createdAt: c.created_at,
url: c.html_url,
isBot: c.user?.type === 'Bot'
})
)
} else {
console.warn('Failed to fetch issue comments:', issueResult.reason)
}
// Parse review threads (GraphQL)
type GQLThread = {
id: string
isResolved: boolean
line: number | null
startLine: number | null
originalLine: number | null
originalStartLine: number | null
comments: {
nodes: {
id: string
databaseId: number
author: { __typename?: string; login: string; avatarUrl: string } | null
body: string
createdAt: string
url: string
path: string
reactionGroups?: GitHubGraphQLReactionGroup[] | null
}[]
}
}
type GQLIssueComment = {
id: string
databaseId: number
author: { __typename?: string; login: string; avatarUrl: string } | null
body: string
createdAt: string
url: string
reactionGroups?: GitHubGraphQLReactionGroup[] | null
}
let graphQLReviewSummaries: PRComment[] | undefined
const reviewComments: PRComment[] = []
if (threadsResult.status === 'fulfilled' && threadsResult.value) {
const threadsData = JSON.parse(threadsResult.value.stdout) as {
data?: {
repository?: {
pullRequest?: {
reviewThreads?: { nodes?: GQLThread[] | null } | null
comments?: { nodes?: GQLIssueComment[] | null } | null
reviews?: { nodes?: GQLIssueComment[] | null } | null
} | null
} | null
} | null
}
// Why: graphql can exit 0 with data.repository null plus an errors array (scopes, field-level denial);
// dereferencing it would throw and drop the REST halves fetched alongside it.
const pullRequest = threadsData.data?.repository?.pullRequest
if (!pullRequest) {
console.warn('Review threads response missing pullRequest; keeping REST results')
}
const graphQLIssueComments = (pullRequest?.comments?.nodes ?? []).map(
(c): PRComment => ({
id: c.databaseId,
author: c.author?.login ?? 'ghost',
authorAvatarUrl: c.author?.avatarUrl ?? '',
body: c.body ?? '',
createdAt: c.createdAt,
url: c.url,
isBot: c.author?.__typename === 'Bot',
reactionSubjectId: c.id,
reactions: mapGraphQLReactionGroups(c.reactionGroups)
})
)
if (graphQLIssueComments.length > 0) {
issueComments = graphQLIssueComments
}
// Why: leave undefined when the payload is incomplete so the REST review summaries stay in use.
graphQLReviewSummaries = pullRequest
? (pullRequest.reviews?.nodes ?? [])
.filter((review) => review.body?.trim())
.map(
(review): PRComment => ({
id: review.databaseId,
author: review.author?.login ?? 'ghost',
authorAvatarUrl: review.author?.avatarUrl ?? '',
body: review.body,
createdAt: review.createdAt,
url: review.url,
isBot: review.author?.__typename === 'Bot',
reactionSubjectId: review.id,
reactions: mapGraphQLReactionGroups(review.reactionGroups)
})
)
: undefined
const threads = pullRequest?.reviewThreads?.nodes ?? []
for (const thread of threads) {
for (const c of thread.comments.nodes) {
reviewComments.push({
id: c.databaseId,
author: c.author?.login ?? 'ghost',
authorAvatarUrl: c.author?.avatarUrl ?? '',
body: c.body ?? '',
createdAt: c.createdAt,
url: c.url,
isBot: c.author?.__typename === 'Bot',
reactionSubjectId: c.id,
reactions: mapGraphQLReactionGroups(c.reactionGroups),
path: c.path,
threadId: thread.id,
isResolved: thread.isResolved,
isOutdated: thread.line == null,
// Why: GitHub nulls line/startLine when the commented code is outdated (e.g. force-push); originalLine preserves the original numbers.
line: thread.line ?? thread.originalLine ?? undefined,
startLine: thread.startLine ?? thread.originalStartLine ?? undefined
})
}
}
} else {
if (threadsResult.status === 'rejected') {
console.warn('Failed to fetch review threads:', threadsResult.reason)
}
}
// Review summaries (REST); skip empty-body reviews (e.g. approvals with no comment) as noise.
type RESTReview = {
id: number
user: { login: string; avatar_url: string; type?: string } | null
body: string
state: string
submitted_at: string
html_url: string
}
let reviewSummaries: PRComment[] = []
if (graphQLReviewSummaries) {
reviewSummaries = graphQLReviewSummaries
} else if (reviewsResult.status === 'fulfilled') {
reviewSummaries = (JSON.parse(reviewsResult.value.stdout) as RESTReview[])
.filter((r) => r.body?.trim())
.map(
(r): PRComment => ({
id: r.id,
author: r.user?.login ?? 'ghost',
authorAvatarUrl: r.user?.avatar_url ?? '',
body: r.body,
createdAt: r.submitted_at,
url: r.html_url,
isBot: r.user?.type === 'Bot'
})
)
} else {
console.warn('Failed to fetch review summaries:', reviewsResult.reason)
}
const all = [...issueComments, ...reviewComments, ...reviewSummaries]
all.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
return all
}
// Fallback: non-GitHub remote — use gh pr view (only returns issue-level comments)
const { stdout } = await ghExecFileAsync(
['pr', 'view', String(prNumber), '--json', 'comments'],
ghOptions
)
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
const data = JSON.parse(stdout) as {
comments: {
author: { login: string }
body: string
createdAt: string
url: string
}[]
}
return (data.comments ?? []).map((c, i) => ({
id: i,
author: c.author?.login ?? 'ghost',
authorAvatarUrl: '',
body: c.body ?? '',
createdAt: c.createdAt,
url: c.url ?? ''
}))
} catch (err) {
console.warn('getPRComments failed:', err)
return []
} finally {
release()
}
}
@@ -0,0 +1,144 @@
import type { IssueSourcePreference } from '../../../../shared/repo-types'
import { acquire, release, classifyGhError, type LocalGitExecOptions } from '../../gh-utils'
import {
resolveGitHubApiRepository,
resolveGitHubApiRepositoryCandidates,
resolveIssueGitHubApiRepositorySource,
type GitHubApiRepository
} from '../../github-api-repository'
import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key'
import type { MainWorkItem } from './../map/work-item-field-coercion'
import {
fetchIssueWorkItem,
fetchPullRequestWorkItem,
fetchPullRequestWorkItemFromCandidates
} from './work-item-fetch'
export async function getWorkItem(
repoPath: string,
number: number,
type?: 'issue' | 'pr',
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {},
preference?: IssueSourcePreference
): Promise<MainWorkItem | null> {
await acquire()
try {
// Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number
// must share that preference so origin/upstream toggles cannot disagree.
if (type === 'issue') {
const { source } = await resolveIssueGitHubApiRepositorySource(
repoPath,
preference,
connectionId,
localGitOptions
)
// Why: explicit origin with no origin identity must not bare-lookup ambient gh
// (same fail-closed rule as origin-pinned PR candidate resolution).
if (!source && preference === 'origin') {
return null
}
return await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions)
}
if (type === 'pr') {
return await fetchPullRequestWorkItemFromCandidates(
repoPath,
number,
connectionId,
localGitOptions,
preference
)
}
try {
const { source } = await resolveIssueGitHubApiRepositorySource(
repoPath,
preference,
connectionId,
localGitOptions
)
if (source || preference !== 'origin') {
const issue = await fetchIssueWorkItem(
repoPath,
source,
number,
connectionId,
localGitOptions
)
if (issue) {
return issue
}
}
} catch (err) {
// Why: only fall through to PR #N on a genuine 404; re-throw transient errors so a flake can't surface an unrelated PR.
const stderr = err instanceof Error ? err.message : String(err)
if (classifyGhError(stderr).type !== 'not_found') {
throw err
}
}
return await fetchPullRequestWorkItemFromCandidates(
repoPath,
number,
connectionId,
localGitOptions,
preference
)
} catch {
return null
} finally {
release()
}
}
export async function getWorkItemByOwnerRepo(
repoPath: string,
ownerRepo: GitHubApiRepository,
number: number,
type: 'issue' | 'pr',
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<MainWorkItem | null> {
const requestedHost = ownerRepo.host?.trim().toLowerCase()
const requestedRepository = requestedHost
? { ...ownerRepo, host: requestedHost }
: await resolveGitHubApiRepository(repoPath, ownerRepo, connectionId, localGitOptions)
if (!requestedRepository) {
return null
}
const { candidates } = await resolveGitHubApiRepositoryCandidates(
repoPath,
connectionId,
localGitOptions
)
const requestedKey = githubRepoIdentityKey(requestedRepository)
const matchedRepository = candidates.find(
(candidate) => githubRepoIdentityKey(candidate) === requestedKey
)
// Why: this lookup is reachable from pasted links. Restricting it to a
// configured remote prevents gh from sending credentials to an arbitrary host.
if (!matchedRepository) {
return null
}
await acquire()
try {
if (type === 'issue') {
return await fetchIssueWorkItem(
repoPath,
matchedRepository,
number,
connectionId,
localGitOptions
)
}
return await fetchPullRequestWorkItem(
repoPath,
matchedRepository,
number,
connectionId,
localGitOptions
)
} catch {
return null
} finally {
release()
}
}
+49
View File
@@ -0,0 +1,49 @@
import { execFileAsync, acquire, release } from '../../gh-utils'
export const ORCA_REPO = 'stablyai/orca'
/**
* Check if the authenticated user has starred the Orca repo.
* Returns true if starred, false if not, null if unable to determine (gh unavailable).
*/
export async function checkOrcaStarred(): Promise<boolean | null> {
await acquire()
try {
const { stdout, stderr } = await execFileAsync(
'gh',
['api', '--include', `user/starred/${ORCA_REPO}`],
{ encoding: 'utf-8' }
)
const response = `${stdout ?? ''}\n${stderr ?? ''}`
if (/HTTP\/\S+\s+(?:200|204)\b/.test(response)) {
return true
}
return null
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
// 404 means the user hasn't starred — the only expected "no" answer
if (message.includes('HTTP 404')) {
return false
}
// Anything else (gh not installed, not authenticated, network issue)
return null
} finally {
release()
}
}
/**
* Star the Orca repo for the authenticated user.
*/
export async function starOrca(): Promise<boolean> {
await acquire()
try {
await execFileAsync('gh', ['api', '-X', 'PUT', `user/starred/${ORCA_REPO}`], {
encoding: 'utf-8'
})
return true
} catch {
return false
} finally {
release()
}
}
@@ -0,0 +1,70 @@
// Why: review thread resolution status + thread IDs are GraphQL-only (REST pulls/{n}/comments omits them).
export const REVIEW_THREADS_QUERY = `
query($owner: String!, $repo: String!, $pr: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
reviewThreads(first: 100) {
nodes {
id
isResolved
line
startLine
originalLine
originalStartLine
comments(first: 100) {
nodes {
id
databaseId
author { __typename login avatarUrl(size: 48) }
body
createdAt
url
path
reactionGroups {
content
viewerHasReacted
reactors {
totalCount
}
}
}
}
}
}
comments(first: 100) {
nodes {
id
databaseId
author { __typename login avatarUrl(size: 48) }
body
createdAt
url
reactionGroups {
content
viewerHasReacted
reactors {
totalCount
}
}
}
}
reviews(first: 100) {
nodes {
id
databaseId
author { __typename login avatarUrl(size: 48) }
body
createdAt
url
reactionGroups {
content
viewerHasReacted
reactors {
totalCount
}
}
}
}
}
}
}`
@@ -0,0 +1,84 @@
import { ghExecFileAsync, acquire, release, type OwnerRepo } from '../../gh-utils'
import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from '../../../source-control/hosted-review-git-options'
import {
getGitHubApiRepositoryForRemote,
getOriginGitHubApiRepository,
githubRepositorySlugArg,
resolveGitHubRepoExecution,
type GitHubApiRepository
} from '../../github-api-repository'
import { hostedReviewLocalGitOptionArgs, sameOwnerRepo } from './../github-exec-scope'
export async function getRepoSlug(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<GitHubApiRepository | null> {
return getOriginGitHubApiRepository(
repoPath,
connectionId,
getHostedReviewLocalGitOptions(options)
)
}
/**
* Resolve a fork's upstream/parent owner/repo, or null when not a fork.
* Why: drives the fork indicator, and a same-name fork's avatar prefers the
* upstream owner (a renamed fork keeps its own owner).
* Best-effort: any failure (offline, unauthed, non-GitHub) resolves to null.
*/
export async function getRepoUpstream(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<OwnerRepo | null> {
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
const localGitOptions = localGitArgs[0] ?? {}
const { ownerRepo: origin, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
undefined,
connectionId,
localGitOptions
)
if (!origin) {
return null
}
const upstreamRemote = await getGitHubApiRepositoryForRemote(
repoPath,
'upstream',
connectionId,
localGitOptions
)
if (upstreamRemote && !sameOwnerRepo(upstreamRemote, origin)) {
return upstreamRemote
}
await acquire()
try {
// Why: positional slugs bypass the runner's --repo qualifier, so the slug
// itself must carry the Enterprise host.
const { stdout } = await ghExecFileAsync(
['repo', 'view', githubRepositorySlugArg(origin), '--json', 'isFork,parent'],
// Why: cap this best-effort add-time lookup so a stalled gh process can't hold up repo creation.
{
...ghOptions,
timeout: 10_000
}
)
const data = JSON.parse(stdout) as {
isFork?: boolean
parent?: { name?: string; owner?: { login?: string } } | null
}
const owner = data.parent?.owner?.login
const repo = data.parent?.name
// Why: a fork parent lives on the same server as the fork.
return data.isFork && owner && repo
? { owner, repo, ...(origin.host ? { host: origin.host } : {}) }
: null
} catch {
return null
} finally {
release()
}
}
@@ -0,0 +1,199 @@
import type { IssueSourcePreference } from '../../../../shared/repo-types'
import {
ghExecFileAsync,
classifyGhError,
ghRepoExecOptions,
githubRepoContext,
type LocalGitExecOptions
} from '../../gh-utils'
import { githubHostExecOptions, type GitHubApiRepository } from '../../github-api-repository'
import type { GhExecOptions } from './../github-exec-scope'
import { resolvePullRequestLookupCandidates } from './../pull-request-lookup-candidates'
import { detectRepositoryMergeMetadata } from './../detect/repository-merge-metadata'
import {
WORK_ITEM_PR_DETAIL_JSON_FIELDS,
usersFromUnknown,
latestReviewsFromUnknown,
type MainWorkItem
} from './../map/work-item-field-coercion'
import { mapIssueWorkItem, mapPullRequestWorkItem } from './../map/work-item'
export async function fetchIssueWorkItem(
repoPath: string,
ownerRepo: GitHubApiRepository | null,
number: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<MainWorkItem | null> {
const ghOptions = {
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
...githubHostExecOptions(ownerRepo)
}
if (ownerRepo) {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${number}`],
ghOptions
)
const item = JSON.parse(stdout) as Record<string, unknown>
if ('pull_request' in item) {
return null
}
return mapIssueWorkItem(item)
}
if (connectionId) {
// Why: SSH-backed gh has no repository cwd. A bare lookup could honor the
// local process GH_REPO/GH_HOST and return an unrelated repository item.
return null
}
const { stdout } = await ghExecFileAsync(
['issue', 'view', String(number), '--json', 'number,title,state,url,labels,updatedAt,author'],
ghOptions
)
return mapIssueWorkItem(JSON.parse(stdout) as Record<string, unknown>)
}
// Why: REST /pulls/{n} lacks latestReviews, so pull review fields from gh so reviewer lists aren't silently empty.
export const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews'
export async function fetchPullRequestReviewFields(
number: number,
ownerRepo: GitHubApiRepository | null,
ghOptions: GhExecOptions
): Promise<Pick<MainWorkItem, 'reviewRequests' | 'latestReviews'>> {
try {
const args = ownerRepo
? [
'pr',
'view',
String(number),
'--repo',
`${ownerRepo.owner}/${ownerRepo.repo}`,
'--json',
WORK_ITEM_PR_REVIEW_JSON_FIELDS
]
: ['pr', 'view', String(number), '--json', WORK_ITEM_PR_REVIEW_JSON_FIELDS]
const { stdout } = await ghExecFileAsync(args, ghOptions)
const item = JSON.parse(stdout) as Record<string, unknown>
return {
...(item.reviewRequests !== undefined
? { reviewRequests: usersFromUnknown(item.reviewRequests) }
: {}),
...(item.latestReviews !== undefined
? { latestReviews: latestReviewsFromUnknown(item.latestReviews) }
: {})
}
} catch {
return {}
}
}
export async function fetchPullRequestWorkItem(
repoPath: string,
ownerRepo: GitHubApiRepository | null,
number: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<MainWorkItem | null> {
const ghOptions = {
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
...githubHostExecOptions(ownerRepo)
}
if (ownerRepo) {
try {
const { stdout } = await ghExecFileAsync(
[
'pr',
'view',
String(number),
'--repo',
`${ownerRepo.owner}/${ownerRepo.repo}`,
'--json',
WORK_ITEM_PR_DETAIL_JSON_FIELDS
],
ghOptions
)
const item = JSON.parse(stdout) as Record<string, unknown>
const mapped = mapPullRequestWorkItem(item, ownerRepo)
// Why: merge-metadata GraphQL is best-effort — don't fall through to REST, which drops latestReviews and blanks bot-only reviewer lists.
const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined
try {
const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions)
return {
...mapped,
mergeQueueRequired: mergeMetadata.mergeQueueRequired,
...(mergeMetadata.autoMergeAllowed !== null
? { autoMergeAllowed: mergeMetadata.autoMergeAllowed }
: {}),
...(mergeMetadata.mergeMethodSettings
? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
: {})
}
} catch {
return mapped
}
} catch {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
ghOptions
)
const mapped = mapPullRequestWorkItem(
JSON.parse(stdout) as Record<string, unknown>,
ownerRepo
)
const reviewFields = await fetchPullRequestReviewFields(number, ownerRepo, ghOptions)
return { ...mapped, ...reviewFields }
}
}
if (connectionId) {
// Why: connection-backed gh cannot infer a repository from cwd. Refuse a
// bare call so process-level GH_REPO/GH_HOST cannot redirect the lookup.
return null
}
const { stdout } = await ghExecFileAsync(
['pr', 'view', String(number), '--json', WORK_ITEM_PR_DETAIL_JSON_FIELDS],
ghOptions
)
return mapPullRequestWorkItem(JSON.parse(stdout) as Record<string, unknown>)
}
export async function fetchPullRequestWorkItemFromCandidates(
repoPath: string,
number: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {},
preference?: IssueSourcePreference
): Promise<MainWorkItem | null> {
const candidates = await resolvePullRequestLookupCandidates(
repoPath,
preference,
connectionId,
localGitOptions
)
if (candidates.length === 0) {
if (preference === 'origin') {
return null
}
return fetchPullRequestWorkItem(repoPath, null, number, connectionId, localGitOptions)
}
for (const candidate of candidates) {
try {
return await fetchPullRequestWorkItem(
repoPath,
candidate,
number,
connectionId,
localGitOptions
)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const classification = classifyGhError(message).type
if (classification !== 'not_found' && classification !== 'permission_denied') {
throw err
}
}
}
return null
}
@@ -0,0 +1,43 @@
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 {
// Why: execFile keeps the API diagnostic in stderr; err.message is only "Command failed".
return classifyGhError(extractExecError(err).stderr).type === 'not_found'
}
export function shouldStopAfterExactLookupError(err: unknown): boolean {
return classifyGhError(extractExecError(err).stderr).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,35 @@
import type { LocalGitExecOptions, OwnerRepo } from '../gh-utils'
import {
hasHostedReviewLocalGitOptions,
getHostedReviewLocalGitOptions
} from '../../source-control/hosted-review-git-options'
import 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,154 @@
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,146 @@
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, 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,241 @@
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 { githubPRStackExecutionScope } from './../github-exec-scope'
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') {
try {
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)
} catch (err) {
// Why: a malformed issue payload must not discard the successfully fetched PR half.
issuesError = classifyListIssuesError(err instanceof Error ? err.message : String(err))
}
} 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) },
githubPRStackExecutionScope(connectionId, localGitOptions)
)
} 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) },
githubPRStackExecutionScope(connectionId, localGitOptions)
)
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,76 @@
import { getPRConflictSummary } from '../../conflict-summary'
import type { ghRepoExecOptions, OwnerRepo } from '../../gh-utils'
import { hydrateGitHubPRStack } from '../../github-pr-stack'
import { detectRepositoryMergeMetadata } from './../detect/repository-merge-metadata'
import { derivePullRequestMergeable, type PullRequestLookupData } from './pull-request-lookup-data'
import { getCachedGitHubPRStackSummary } from './pr-stack-summary-cache'
export async function derivePRRefreshData(args: {
data: PullRequestLookupData
dataRepo: OwnerRepo | null
repoPath: string
connectionId?: string | null
localGitOptions: { wslDistro?: string }
ghOptions: ReturnType<typeof ghRepoExecOptions>
executionScope: string
usedExactNumberLookup: boolean
}): Promise<{
mergeable: ReturnType<typeof derivePullRequestMergeable>
stack: PullRequestLookupData['stack']
stackMergeQueueRequired: boolean | null | undefined
conflictSummary: Awaited<ReturnType<typeof getPRConflictSummary>>
}> {
const { data, dataRepo, repoPath, connectionId, localGitOptions, ghOptions, executionScope } =
args
if (!data.stackMetadataChecked && dataRepo && args.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 { mergeable, stack, stackMergeQueueRequired, conflictSummary }
}
@@ -0,0 +1,294 @@
import type { PRRefreshOutcome } from '../../../../shared/github/pull-request-refresh-types'
import type { ghRepoExecOptions, 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 } from '../../mappers'
import { noteRepositoryRateLimitSpend } from '../../rate-limit'
import { ownerRepoFromPullRequestUrl } from './../github-exec-scope'
import { prRefreshUpstreamError } from './../gh-error-predicates'
import {
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 { derivePRRefreshData } from './branch-lookup-derived-data'
import { assemblePRRefreshFoundOutcome } from './pr-refresh-outcome-assembly'
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() }
}
const { mergeable, stack, stackMergeQueueRequired, conflictSummary } = await derivePRRefreshData({
data,
dataRepo,
repoPath,
connectionId,
localGitOptions,
ghOptions,
executionScope,
usedExactNumberLookup
})
return assemblePRRefreshFoundOutcome({
data,
dataRepo,
dataHeadRepo,
stack,
mergeable,
stackMergeQueueRequired,
confirmedContainedHeadOid,
headDivergedFromMergedPRAtOid,
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,163 @@
import { ghExecFileAsync } from '../../gh-utils'
import type { OwnerRepo, ghRepoExecOptions } 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,44 @@
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,70 @@
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,152 @@
import { ghExecFileAsync } from '../../gh-utils'
import type { OwnerRepo, ghRepoExecOptions } 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,70 @@
import type { PRRefreshOutcome } from '../../../../shared/github/pull-request-refresh-types'
import type {
PRConflictSummary,
PRMergeableState,
GitHubPRStack
} from '../../../../shared/github/pull-request-types'
import { deriveCheckStatus, mapPRState } from '../../mappers'
import type { OwnerRepo } from '../../gh-utils'
import type { PullRequestLookupData } from './pull-request-lookup-data'
export function assemblePRRefreshFoundOutcome(args: {
data: PullRequestLookupData
dataRepo: OwnerRepo | null
dataHeadRepo: OwnerRepo | null
stack: GitHubPRStack | undefined
mergeable: PRMergeableState
stackMergeQueueRequired: boolean | null | undefined
confirmedContainedHeadOid: string | null
headDivergedFromMergedPRAtOid: string | null
conflictSummary: PRConflictSummary | undefined
}): PRRefreshOutcome {
const {
data,
dataRepo,
dataHeadRepo,
stack,
mergeable,
stackMergeQueueRequired,
confirmedContainedHeadOid,
headDivergedFromMergedPRAtOid,
conflictSummary
} = args
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,92 @@
import type { GitHubPRStack } from '../../../../shared/github/pull-request-types'
import type { 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
// Why: a failed probe must not masquerade as "no stack" for a full minute; retry it sooner.
export const PR_STACK_SUMMARY_FAILURE_CACHE_TTL_MS = 5_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.'
type PRStackSummaryCacheEntry =
| { ok: true; value: GitHubPRStack | undefined; expiresAt: number }
| { ok: false; error: unknown; expiresAt: number }
export const prStackSummaryCache = new Map<string, PRStackSummaryCacheEntry>()
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) {
// Why: a cached failure must stay a failure — returning undefined would read as "this PR has no stack".
if (!cached.ok) {
throw cached.error
}
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, {
ok: true,
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, on a short cooldown.
prStackSummaryCache.delete(key)
prStackSummaryCache.set(key, {
ok: false,
error: err,
expiresAt: Date.now() + PR_STACK_SUMMARY_FAILURE_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,180 @@
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,33 @@
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,154 @@
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
// Why: GHES port-443 SSH uses `ssh.<enterprise-host>`, not just ssh.github.com.
if (originUrl && (/^(git@|ssh:)/.test(originUrl) || /:\/\/(?:[^@/]+@)?ssh\./.test(originUrl))) {
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,191 @@
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,213 @@
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 {
// Why: Number(null) is 0 — an explicit null must stay "unknown", not become a real count or user id.
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined
}
if (typeof value !== 'string' || !value.trim()) {
return undefined
}
const number = 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 ?? '')
}
})
)
}
+138
View File
@@ -0,0 +1,138 @@
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 } : {})
}
}
+172
View File
@@ -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,192 @@
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 { githubPRStackExecutionScope, 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,
executionScope?: string
): Promise<boolean> {
if (!ownerRepo || !pr.baseRefName) {
return false
}
const mergeMetadata = await detectRepositoryMergeMetadata(
ownerRepo,
pr.baseRefName,
ghOptions,
executionScope
)
return mergeMetadata.mergeQueueRequired === true
}
export async function enablePRAutoMerge(
prNumber: number,
method: GitHubPRMergeMethod,
ownerRepo: GitHubApiRepository | null,
ghOptions: GhExecOptions,
executionScope?: string
): 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, executionScope)
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,
githubPRStackExecutionScope(connectionId, localGitOptions)
)
}
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,21 @@
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()
}
}
+100
View File
@@ -0,0 +1,100 @@
import {
ghExecFileAsync,
acquire,
release,
classifyPullRequestUpdateError,
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: classifyPullRequestUpdateError(message).message }
} finally {
release()
}
}
@@ -0,0 +1,62 @@
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit'
/**
* 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 }
}
}`
// Why: viewed toggles fire once per file while reading a diff — the highest-volume GraphQL caller here.
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',
`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()
}
}
+46
View File
@@ -0,0 +1,46 @@
import type { GitHubPullRequestStateUpdate } from '../../../../shared/issue-mutation-types'
import {
ghExecFileAsync,
acquire,
release,
classifyPullRequestUpdateError,
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: classifyPullRequestUpdateError(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()
}
}
@@ -56,6 +56,24 @@ export function classifyListIssuesError(stderr: string): ClassifiedError {
return { type: c.type, message: readMessages[c.type] }
}
// Why: classifyGhError's copy names an "issue"; PR mutations reuse the same
// classification but must not tell the user their pull request is an issue.
export function classifyPullRequestUpdateError(stderr: string): ClassifiedError {
const c = classifyGhError(stderr)
const trimmed = stderr.trim()
const pullRequestMessages: Record<ClassifiedError['type'], string> = {
permission_denied:
"You don't have permission to edit this pull request. Check your GitHub token scopes.",
not_found: 'Pull request not found — it may have been deleted.',
issues_disabled: c.message,
validation_error: `Invalid update — ${trimmed}`,
rate_limited: c.message,
network_error: c.message,
unknown: `Failed to update pull request: ${trimmed}`
}
return { type: c.type, message: pullRequestMessages[c.type] }
}
// Why: PR-side list failures need the same read-op classification — pagination
// decisions key on the type, and swallowing them made failures look like
// end-of-data (#11485).
@@ -63,3 +81,21 @@ export function classifyListPrsError(stderr: string): ClassifiedError {
const c = classifyGhError(stderr)
return { type: c.type, message: `Failed to load pull requests: ${stderr.trim()}` }
}
// Why: classifyGhError's copy names an "issue"; a failed check rerun must name
// the operation the user actually triggered.
export function classifyRerunChecksError(stderr: string): ClassifiedError {
const c = classifyGhError(stderr)
const trimmed = stderr.trim()
const rerunMessages: Record<ClassifiedError['type'], string> = {
permission_denied:
"You don't have permission to rerun checks on this repository. Check your GitHub token scopes.",
not_found: 'GitHub resource to rerun was not found — it may have expired or been deleted.',
issues_disabled: `Failed to rerun checks: ${trimmed}`,
validation_error: `Could not rerun checks — ${trimmed}`,
rate_limited: c.message,
network_error: c.message,
unknown: `Failed to rerun checks: ${trimmed}`
}
return { type: c.type, message: rerunMessages[c.type] }
}
+2 -1
View File
@@ -12,7 +12,8 @@ export { ghExecFileAsync, gitExecFileAsync, extractExecError, parseRetryAfterMs
export {
classifyGhError,
classifyListIssuesError,
classifyListPrsError
classifyListPrsError,
classifyPullRequestUpdateError
} from './gh-error-classification'
export {
_getOwnerRepoCacheSize,
@@ -115,7 +115,9 @@ describe('updater', () => {
const holdRevalidation = (): {
settle: (verdict: RevalidationVerdict) => void
fail: (error: Error) => void
invocationCount: () => number
} => {
let invocationCount = 0
let pending: {
resolve: (verdict: RevalidationVerdict) => void
reject: (error: Error) => void
@@ -126,12 +128,12 @@ describe('updater', () => {
)
return {
...actual,
revalidateLinuxPackageForInstall: vi.fn(
() =>
new Promise<RevalidationVerdict>((resolve, reject) => {
pending = { resolve, reject }
})
)
revalidateLinuxPackageForInstall: vi.fn(() => {
invocationCount += 1
return new Promise<RevalidationVerdict>((resolve, reject) => {
pending = { resolve, reject }
})
})
}
})
return {
@@ -142,7 +144,8 @@ describe('updater', () => {
fail: (error) => {
pending?.reject(error)
pending = null
}
},
invocationCount: () => invocationCount
}
}
@@ -589,13 +592,19 @@ describe('updater', () => {
// Why: a second click during the multi-second hash must not schedule a parallel install.
it('ignores a second install request while the digest re-proof runs', async () => {
const revalidation = holdRevalidation()
const { updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
updater.quitAndInstall()
// Fires the quit timer, which starts the hash; the read itself is still outstanding.
// Fires the quit timer, which starts the re-proof; its verdict is still outstanding.
await vi.advanceTimersByTimeAsync(100)
expect(revalidation.invocationCount()).toBe(1)
updater.quitAndInstall()
// Advancing here proves the second request never scheduled its own quit timer.
await vi.advanceTimersByTimeAsync(100)
expect(revalidation.invocationCount()).toBe(1)
revalidation.settle({ ok: true })
await settleQuitAndInstall()
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)