mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
Make repository identity host-aware across all GitHub surfaces
Generalize the auth-gated enterprise resolver to any remote and build a cached hosted-identity family (origin/issue/candidates/source) on top of it, then migrate every github.com-only consumer: Tasks listing/counting, branch-to-PR discovery, push targets, fork upstream, issue operations, Projects, web links, avatars, and PR-link facts. Scope the rate-limit breaker probe per runtime:host and classify WSL UNC cwds correctly. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
isGhPrimaryRateLimitStderr,
|
||||
isGhRateLimitProbe,
|
||||
notifyGhPrimaryRateLimit,
|
||||
parseGhRateLimitScopeKey,
|
||||
recordGhPrimaryRateLimit,
|
||||
registerGhRateLimitResetProbe
|
||||
} from './gh-rate-limit-breaker'
|
||||
@@ -59,6 +60,36 @@ describe('isGhPrimaryRateLimitStderr', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseGhRateLimitScopeKey', () => {
|
||||
it('parses native and wsl scopes, keeping ported hosts intact', () => {
|
||||
expect(parseGhRateLimitScopeKey('native:github.com')).toEqual({
|
||||
runtime: 'native',
|
||||
host: 'github.com'
|
||||
})
|
||||
expect(parseGhRateLimitScopeKey('native:ghe.corp:8443')).toEqual({
|
||||
runtime: 'native',
|
||||
host: 'ghe.corp:8443'
|
||||
})
|
||||
expect(parseGhRateLimitScopeKey('wsl:ubuntu:github.com')).toEqual({
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'ubuntu',
|
||||
host: 'github.com'
|
||||
})
|
||||
expect(parseGhRateLimitScopeKey('wsl:ubuntu:ghe.corp:8443')).toEqual({
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'ubuntu',
|
||||
host: 'ghe.corp:8443'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed scopes', () => {
|
||||
expect(parseGhRateLimitScopeKey('native:')).toBeNull()
|
||||
expect(parseGhRateLimitScopeKey('wsl:ubuntu')).toBeNull()
|
||||
expect(parseGhRateLimitScopeKey('wsl:ubuntu:')).toBeNull()
|
||||
expect(parseGhRateLimitScopeKey('ssh:github.com')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('breaker state', () => {
|
||||
it('blocks until the recorded time, then expires', () => {
|
||||
const now = 1_000_000
|
||||
@@ -90,17 +121,22 @@ describe('breaker state', () => {
|
||||
const probe = vi.fn()
|
||||
registerGhRateLimitResetProbe(probe)
|
||||
notifyGhPrimaryRateLimit('search')
|
||||
expect(probe).toHaveBeenCalledWith('search')
|
||||
expect(probe).toHaveBeenCalledWith('search', 'native:github.com')
|
||||
expect(getGhRateLimitBlockedUntilMs('search')).toBeGreaterThan(Date.now())
|
||||
})
|
||||
|
||||
it('does not refine a non-default scope with the native github.com probe', () => {
|
||||
it('fires the reset probe with the tripping scope for non-default scopes', () => {
|
||||
// Why: a WSL-only Windows install (or GHES) would otherwise stay on the
|
||||
// blunt 5-minute fallback forever — the probe must learn which scope to
|
||||
// query.
|
||||
const probe = vi.fn()
|
||||
registerGhRateLimitResetProbe(probe)
|
||||
|
||||
notifyGhPrimaryRateLimit('core', 'native:github.acme-corp.com')
|
||||
notifyGhPrimaryRateLimit('core', 'wsl:ubuntu:github.com')
|
||||
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
expect(probe).toHaveBeenNthCalledWith(1, 'core', 'native:github.acme-corp.com')
|
||||
expect(probe).toHaveBeenNthCalledWith(2, 'core', 'wsl:ubuntu:github.com')
|
||||
expect(
|
||||
getGhRateLimitBlockedUntilMs('core', Date.now(), 'native:github.acme-corp.com')
|
||||
).not.toBeNull()
|
||||
|
||||
@@ -31,9 +31,34 @@ export function ghRateLimitScopeKey(runtime: string, host: string): string {
|
||||
return `${runtime}:${host.toLowerCase()}`
|
||||
}
|
||||
|
||||
export type GhRateLimitScopeParts =
|
||||
| { runtime: 'native'; host: string }
|
||||
| { runtime: 'wsl'; wslDistro: string; host: string }
|
||||
|
||||
// Why: the reset probe must spawn gh against the tripping scope's runtime and
|
||||
// host. Parsing lives next to the builder so the key format stays private to
|
||||
// this module instead of being string-split at call sites.
|
||||
export function parseGhRateLimitScopeKey(scope: string): GhRateLimitScopeParts | null {
|
||||
if (scope.startsWith('native:')) {
|
||||
const host = scope.slice('native:'.length)
|
||||
return host ? { runtime: 'native', host } : null
|
||||
}
|
||||
if (scope.startsWith('wsl:')) {
|
||||
// Hosts may carry a port (host:port), so only the first colon after the
|
||||
// distro is a separator.
|
||||
const rest = scope.slice('wsl:'.length)
|
||||
const sep = rest.indexOf(':')
|
||||
if (sep <= 0 || sep === rest.length - 1) {
|
||||
return null
|
||||
}
|
||||
return { runtime: 'wsl', wslDistro: rest.slice(0, sep), host: rest.slice(sep + 1) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const DEFAULT_SCOPE = ghRateLimitScopeKey('native', 'github.com')
|
||||
const blockedUntilMsByScopeAndBucket = new Map<string, number>()
|
||||
let resetProbe: ((bucket: GhRateLimitBucket) => void) | null = null
|
||||
let resetProbe: ((bucket: GhRateLimitBucket, scope: string) => void) | null = null
|
||||
|
||||
function breakerKey(bucket: GhRateLimitBucket, scope = DEFAULT_SCOPE): string {
|
||||
return `${scope}\0${bucket}`
|
||||
@@ -148,7 +173,7 @@ export function getGhRateLimitBlockedUntilMs(
|
||||
/** Register the (single) callback that refreshes precise reset times after a
|
||||
* breaker trip. Registered by the github rate-limit prober at module load. */
|
||||
export function registerGhRateLimitResetProbe(
|
||||
probe: ((bucket: GhRateLimitBucket) => void) | null
|
||||
probe: ((bucket: GhRateLimitBucket, scope: string) => void) | null
|
||||
): void {
|
||||
resetProbe = probe
|
||||
}
|
||||
@@ -156,13 +181,10 @@ export function registerGhRateLimitResetProbe(
|
||||
/** Called by the runner when a gh spawn came back with a primary 403. */
|
||||
export function notifyGhPrimaryRateLimit(bucket: GhRateLimitBucket, scope = DEFAULT_SCOPE): void {
|
||||
recordGhPrimaryRateLimit(bucket, Date.now() + FALLBACK_BLOCK_MS[bucket], scope)
|
||||
// The registered probe uses native github.com credentials; it cannot refine
|
||||
// a GHES or WSL bucket without querying the wrong account/runtime.
|
||||
if (scope !== DEFAULT_SCOPE) {
|
||||
return
|
||||
}
|
||||
// The probe receives the tripping scope so it can run gh against that
|
||||
// runtime/host and refine the fallback block into the real reset time.
|
||||
try {
|
||||
resetProbe?.(bucket)
|
||||
resetProbe?.(bucket, scope)
|
||||
} catch {
|
||||
// best-effort refinement; the fallback block stands
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ vi.mock('child_process', () => ({
|
||||
}))
|
||||
|
||||
import { ghExecFileAsync } from './runner'
|
||||
import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker'
|
||||
import {
|
||||
_resetGhRateLimitBreaker,
|
||||
getGhRateLimitBlockedUntilMs,
|
||||
registerGhRateLimitResetProbe
|
||||
} from './gh-rate-limit-breaker'
|
||||
|
||||
const PRIMARY_RATE_LIMIT_STDERR =
|
||||
'gh: API rate limit exceeded for user ID 1775218. Please wait. (HTTP 403)'
|
||||
@@ -104,6 +108,37 @@ describe('ghExecFileAsync rate-limit breaker', () => {
|
||||
expect(execFileMock).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('scopes a WSL UNC-cwd 403 to the distro runtime and probes that scope', async () => {
|
||||
// Why: a \\wsl.localhost\... cwd routes gh through wsl.exe, so the 403
|
||||
// belongs to the distro's account — the native scope must stay usable and
|
||||
// the reset probe must be told which scope tripped.
|
||||
const probe = vi.fn()
|
||||
registerGhRateLimitResetProbe(probe)
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
try {
|
||||
mockGhFailure(PRIMARY_RATE_LIMIT_STDERR)
|
||||
await expect(
|
||||
ghExecFileAsync(['api', 'repos/a/b/pulls'], {
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'
|
||||
})
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(probe).toHaveBeenCalledWith('core', 'wsl:ubuntu:github.com')
|
||||
expect(
|
||||
getGhRateLimitBlockedUntilMs('core', Date.now(), 'wsl:ubuntu:github.com')
|
||||
).not.toBeNull()
|
||||
expect(getGhRateLimitBlockedUntilMs('core', Date.now())).toBeNull()
|
||||
|
||||
mockGhSuccess('[]')
|
||||
await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).resolves.toMatchObject({
|
||||
stdout: '[]'
|
||||
})
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('never blocks the exempt rate_limit probe', async () => {
|
||||
mockGhFailure(PRIMARY_RATE_LIMIT_STDERR)
|
||||
await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).rejects.toThrow()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GithubApiRepositoryModule from './github-api-repository'
|
||||
import type * as GhUtils from './gh-utils'
|
||||
|
||||
const {
|
||||
@@ -50,6 +51,37 @@ vi.mock('./rate-limit', () => ({
|
||||
spendsSharedGitHubComQuota: () => true
|
||||
}))
|
||||
|
||||
vi.mock('./github-api-repository', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof GithubApiRepositoryModule>()
|
||||
return {
|
||||
...actual,
|
||||
// Why: these suites drive source resolution through the legacy gh-utils
|
||||
// mocks; bridge the hosted seams onto the same mocks.
|
||||
resolveIssueGitHubApiRepositorySource: (
|
||||
repoPath: string,
|
||||
preference: unknown,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions),
|
||||
getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) =>
|
||||
getIssueOwnerRepoMock(repoPath, connectionId),
|
||||
getOriginGitHubApiRepository: (
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) => getOwnerRepoMock(repoPath, connectionId, localGitOptions),
|
||||
getGitHubApiRepositoryForRemote: (
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) =>
|
||||
remoteName === 'origin'
|
||||
? getOwnerRepoMock(repoPath, connectionId, localGitOptions)
|
||||
: getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions)
|
||||
}
|
||||
})
|
||||
|
||||
import { countWorkItems, getWorkItem, listWorkItems, _resetOwnerRepoCache } from './client'
|
||||
|
||||
const PR_LIST_FIELDS =
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GithubApiRepositoryModule from './github-api-repository'
|
||||
|
||||
const {
|
||||
execFileAsyncMock,
|
||||
ghExecFileAsyncMock,
|
||||
getOwnerRepoMock,
|
||||
getIssueOwnerRepoMock,
|
||||
getOwnerRepoForRemoteMock,
|
||||
resolveIssueSourceMock,
|
||||
gitExecFileAsyncMock,
|
||||
rateLimitGuardMock,
|
||||
noteRateLimitSpendMock,
|
||||
acquireMock,
|
||||
releaseMock
|
||||
} = vi.hoisted(() => ({
|
||||
execFileAsyncMock: vi.fn(),
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
getOwnerRepoMock: vi.fn(),
|
||||
getIssueOwnerRepoMock: vi.fn(),
|
||||
getOwnerRepoForRemoteMock: vi.fn(),
|
||||
resolveIssueSourceMock: vi.fn(),
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
rateLimitGuardMock: vi.fn((_bucket?: unknown) => ({ blocked: false })),
|
||||
noteRateLimitSpendMock: vi.fn(),
|
||||
acquireMock: vi.fn(),
|
||||
releaseMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./gh-utils', () => ({
|
||||
execFileAsync: execFileAsyncMock,
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
githubRepoContext: (
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: { wslDistro?: string } = {}
|
||||
) => ({
|
||||
repoPath,
|
||||
connectionId: connectionId ?? null,
|
||||
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
|
||||
}),
|
||||
ghRepoExecOptions: (context: {
|
||||
repoPath: string
|
||||
connectionId?: string | null
|
||||
wslDistro?: string
|
||||
}) =>
|
||||
context.connectionId
|
||||
? {}
|
||||
: { cwd: context.repoPath, ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) },
|
||||
getOwnerRepo: getOwnerRepoMock,
|
||||
getIssueOwnerRepo: getIssueOwnerRepoMock,
|
||||
getOwnerRepoForRemote: getOwnerRepoForRemoteMock,
|
||||
resolveIssueSource: resolveIssueSourceMock,
|
||||
acquire: acquireMock,
|
||||
release: releaseMock,
|
||||
_resetOwnerRepoCache: vi.fn(),
|
||||
classifyGhError: (stderr: string) => ({ type: 'unknown', message: stderr }),
|
||||
classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr })
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('./rate-limit', () => ({
|
||||
rateLimitGuard: rateLimitGuardMock,
|
||||
noteRateLimitSpend: noteRateLimitSpendMock,
|
||||
getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })),
|
||||
// Mirror production: shared-scope calls delegate to the global guard/spend.
|
||||
repositoryRateLimitGuard: vi.fn((_repo: unknown, bucket: string) => rateLimitGuardMock(bucket)),
|
||||
noteRepositoryRateLimitSpend: vi.fn((_repo: unknown, bucket: string, cost?: number) =>
|
||||
noteRateLimitSpendMock(bucket, cost)
|
||||
),
|
||||
spendsSharedGitHubComQuota: () => true
|
||||
}))
|
||||
|
||||
vi.mock('./github-api-repository', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof GithubApiRepositoryModule>()
|
||||
return {
|
||||
...actual,
|
||||
// Why: these suites drive source resolution through the legacy gh-utils
|
||||
// mocks; bridge the hosted seams onto the same mocks.
|
||||
resolveIssueGitHubApiRepositorySource: (
|
||||
repoPath: string,
|
||||
preference: unknown,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions),
|
||||
getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) =>
|
||||
getIssueOwnerRepoMock(repoPath, connectionId),
|
||||
getOriginGitHubApiRepository: (
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) => getOwnerRepoMock(repoPath, connectionId, localGitOptions),
|
||||
getGitHubApiRepositoryForRemote: (
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) =>
|
||||
remoteName === 'origin'
|
||||
? getOwnerRepoMock(repoPath, connectionId, localGitOptions)
|
||||
: getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions)
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
countWorkItems,
|
||||
listWorkItems,
|
||||
_resetMergeQueueCacheForTests,
|
||||
_resetOwnerRepoCache
|
||||
} from './client'
|
||||
import { _resetGhCwdRepoNegativeCache } from './gh-cwd-repo-negative-cache'
|
||||
import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds'
|
||||
|
||||
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
|
||||
|
||||
// The origin-repository cache is module-level state; reset it so slugs
|
||||
// resolved by one test cannot leak into the next.
|
||||
beforeEach(() => {
|
||||
_resetOriginGitHubApiRepositoryCache()
|
||||
})
|
||||
// Why: split from client-work-items.test.ts to keep both suites under the
|
||||
// max-lines cap; this file owns query/paging request-shaping cases.
|
||||
describe('listWorkItems query paging', () => {
|
||||
beforeEach(() => {
|
||||
execFileAsyncMock.mockReset()
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
getOwnerRepoMock.mockReset()
|
||||
getIssueOwnerRepoMock.mockReset()
|
||||
getOwnerRepoForRemoteMock.mockReset()
|
||||
resolveIssueSourceMock.mockReset()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
rateLimitGuardMock.mockReset()
|
||||
rateLimitGuardMock.mockReturnValue({ blocked: false })
|
||||
noteRateLimitSpendMock.mockReset()
|
||||
acquireMock.mockReset()
|
||||
releaseMock.mockReset()
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
// Why: preference-aware `listWorkItems` calls `resolveIssueSource`.
|
||||
// Route through the same `getIssueOwnerRepoMock` so existing tests that
|
||||
// only set up `getIssueOwnerRepoMock` continue to work.
|
||||
resolveIssueSourceMock.mockImplementation(async () => ({
|
||||
source: await getIssueOwnerRepoMock(),
|
||||
fellBack: false
|
||||
}))
|
||||
getOwnerRepoForRemoteMock.mockResolvedValue(null)
|
||||
_resetOwnerRepoCache()
|
||||
_resetMergeQueueCacheForTests()
|
||||
_resetGhCwdRepoNegativeCache()
|
||||
})
|
||||
|
||||
it('returns zero for oversized count queries before resolving repo sources', async () => {
|
||||
const secret = 'main-github-work-items-secret'
|
||||
const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES)
|
||||
|
||||
await expect(countWorkItems('/repo-root', oversizedQuery)).resolves.toBe(0)
|
||||
|
||||
expect(resolveIssueSourceMock).not.toHaveBeenCalled()
|
||||
expect(getIssueOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(getOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(acquireMock).not.toHaveBeenCalled()
|
||||
expect(releaseMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes review-requested as a --search qualifier (gh CLI has no dedicated flag)', async () => {
|
||||
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
await listWorkItems('/repo-root', 10, 'review-requested:@me is:open')
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['--search', 'is:pr is:open review-requested:@me sort:created-desc']),
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['--review-requested']),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the requested numbered Search API page for issues', async () => {
|
||||
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
await listWorkItems('/repo-root', 10, 'is:issue is:open', 2)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'api',
|
||||
'--cache',
|
||||
'120s',
|
||||
`search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=2`,
|
||||
'--jq',
|
||||
'.items'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
})
|
||||
|
||||
it('fetches and slices stable PR results for the requested numbered page', async () => {
|
||||
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify(
|
||||
[1, 2, 3, 4].map((number) => ({
|
||||
number,
|
||||
title: `PR ${number}`,
|
||||
state: 'OPEN',
|
||||
url: `https://github.com/acme/widgets/pull/${number}`,
|
||||
labels: [],
|
||||
updatedAt: `2026-07-0${number}T00:00:00Z`,
|
||||
author: { login: 'octocat' },
|
||||
isDraft: false,
|
||||
headRefName: `feature/${number}`,
|
||||
headRefOid: `head-${number}`,
|
||||
baseRefName: 'main'
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
const { items } = await listWorkItems('/repo-root', 2, 'is:pr is:open', 2)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['--limit', '4', '--search', 'is:pr is:open sort:created-desc']),
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(items.map((item) => item.number)).toEqual([4, 3])
|
||||
})
|
||||
|
||||
it('filters pull request rows out of issue Search API results', async () => {
|
||||
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 2,
|
||||
title: 'PR-shaped search row',
|
||||
state: 'open',
|
||||
html_url: 'https://github.com/acme/widgets/pull/2',
|
||||
labels: [],
|
||||
updated_at: '2026-07-02T00:00:00Z',
|
||||
user: { login: 'octocat' },
|
||||
pull_request: { url: 'https://api.github.com/repos/acme/widgets/pulls/2' }
|
||||
},
|
||||
{
|
||||
number: 1,
|
||||
title: 'Issue row',
|
||||
state: 'open',
|
||||
html_url: 'https://github.com/acme/widgets/issues/1',
|
||||
labels: [],
|
||||
updated_at: '2026-07-01T00:00:00Z',
|
||||
user: { login: 'octocat' }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
const { items } = await listWorkItems('/repo-root', 10, 'is:issue is:open')
|
||||
|
||||
expect(items.map((item) => item.id)).toEqual(['issue:1'])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GithubApiRepositoryModule from './github-api-repository'
|
||||
|
||||
const {
|
||||
execFileAsyncMock,
|
||||
@@ -73,6 +74,37 @@ vi.mock('./rate-limit', () => ({
|
||||
spendsSharedGitHubComQuota: () => true
|
||||
}))
|
||||
|
||||
vi.mock('./github-api-repository', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof GithubApiRepositoryModule>()
|
||||
return {
|
||||
...actual,
|
||||
// Why: these suites drive source resolution through the legacy gh-utils
|
||||
// mocks; bridge the hosted seams onto the same mocks.
|
||||
resolveIssueGitHubApiRepositorySource: (
|
||||
repoPath: string,
|
||||
preference: unknown,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions),
|
||||
getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) =>
|
||||
getIssueOwnerRepoMock(repoPath, connectionId),
|
||||
getOriginGitHubApiRepository: (
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) => getOwnerRepoMock(repoPath, connectionId, localGitOptions),
|
||||
getGitHubApiRepositoryForRemote: (
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) =>
|
||||
remoteName === 'origin'
|
||||
? getOwnerRepoMock(repoPath, connectionId, localGitOptions)
|
||||
: getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions)
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
countWorkItems,
|
||||
listWorkItems,
|
||||
@@ -118,6 +150,21 @@ describe('listWorkItems', () => {
|
||||
_resetGhCwdRepoNegativeCache()
|
||||
})
|
||||
|
||||
it('routes GHES work-item listing through the Enterprise host', async () => {
|
||||
const ghes = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }
|
||||
getIssueOwnerRepoMock.mockResolvedValue(ghes)
|
||||
getOwnerRepoMock.mockResolvedValue(ghes)
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: '[]' })
|
||||
|
||||
await listWorkItems('/repo-root', 10)
|
||||
|
||||
const calls = ghExecFileAsyncMock.mock.calls
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
// Why: every list spawn must pin options.host so the runner targets the
|
||||
// Enterprise server instead of gh's default host.
|
||||
expect(calls.every(([, options]) => options?.host === 'github.acme-corp.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('stops re-spawning gh for a repo whose cwd resolution already failed with no remotes', async () => {
|
||||
getIssueOwnerRepoMock.mockResolvedValue(null)
|
||||
getOwnerRepoMock.mockResolvedValue(null)
|
||||
@@ -594,120 +641,6 @@ describe('listWorkItems', () => {
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns zero for oversized count queries before resolving repo sources', async () => {
|
||||
const secret = 'main-github-work-items-secret'
|
||||
const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES)
|
||||
|
||||
await expect(countWorkItems('/repo-root', oversizedQuery)).resolves.toBe(0)
|
||||
|
||||
expect(resolveIssueSourceMock).not.toHaveBeenCalled()
|
||||
expect(getIssueOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(getOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(acquireMock).not.toHaveBeenCalled()
|
||||
expect(releaseMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes review-requested as a --search qualifier (gh CLI has no dedicated flag)', async () => {
|
||||
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
await listWorkItems('/repo-root', 10, 'review-requested:@me is:open')
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['--search', 'is:pr is:open review-requested:@me sort:created-desc']),
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['--review-requested']),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the requested numbered Search API page for issues', async () => {
|
||||
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
await listWorkItems('/repo-root', 10, 'is:issue is:open', 2)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'api',
|
||||
'--cache',
|
||||
'120s',
|
||||
`search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=2`,
|
||||
'--jq',
|
||||
'.items'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
})
|
||||
|
||||
it('fetches and slices stable PR results for the requested numbered page', async () => {
|
||||
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify(
|
||||
[1, 2, 3, 4].map((number) => ({
|
||||
number,
|
||||
title: `PR ${number}`,
|
||||
state: 'OPEN',
|
||||
url: `https://github.com/acme/widgets/pull/${number}`,
|
||||
labels: [],
|
||||
updatedAt: `2026-07-0${number}T00:00:00Z`,
|
||||
author: { login: 'octocat' },
|
||||
isDraft: false,
|
||||
headRefName: `feature/${number}`,
|
||||
headRefOid: `head-${number}`,
|
||||
baseRefName: 'main'
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
const { items } = await listWorkItems('/repo-root', 2, 'is:pr is:open', 2)
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['--limit', '4', '--search', 'is:pr is:open sort:created-desc']),
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(items.map((item) => item.number)).toEqual([4, 3])
|
||||
})
|
||||
|
||||
it('filters pull request rows out of issue Search API results', async () => {
|
||||
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 2,
|
||||
title: 'PR-shaped search row',
|
||||
state: 'open',
|
||||
html_url: 'https://github.com/acme/widgets/pull/2',
|
||||
labels: [],
|
||||
updated_at: '2026-07-02T00:00:00Z',
|
||||
user: { login: 'octocat' },
|
||||
pull_request: { url: 'https://api.github.com/repos/acme/widgets/pulls/2' }
|
||||
},
|
||||
{
|
||||
number: 1,
|
||||
title: 'Issue row',
|
||||
state: 'open',
|
||||
html_url: 'https://github.com/acme/widgets/issues/1',
|
||||
labels: [],
|
||||
updated_at: '2026-07-01T00:00:00Z',
|
||||
user: { login: 'octocat' }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
const { items } = await listWorkItems('/repo-root', 10, 'is:issue is:open')
|
||||
|
||||
expect(items.map((item) => item.id)).toEqual(['issue:1'])
|
||||
})
|
||||
|
||||
it('returns open issues and PRs for the all-open preset query', async () => {
|
||||
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* oxlint-disable max-lines -- Why: GitHub client fixtures cover local and SSH repo identity paths in one suite so mocked CLI behavior stays consistent. */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GithubApiRepositoryModule from './github-api-repository'
|
||||
|
||||
type RateLimitGuardResult =
|
||||
| { blocked: false }
|
||||
@@ -111,6 +112,38 @@ vi.mock('./rate-limit', () => ({
|
||||
spendsSharedGitHubComQuota: () => true
|
||||
}))
|
||||
|
||||
vi.mock('./github-api-repository', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof GithubApiRepositoryModule>()
|
||||
return {
|
||||
...actual,
|
||||
// Why: these suites inject repo identities through the legacy gh-utils
|
||||
// mocks; bridge the hosted seams onto the same mocks so per-test setups
|
||||
// keep driving resolution without real enterprise probes.
|
||||
resolveGitHubApiRepositoryCandidates: (
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) => resolvePRRepositoryCandidatesMock(repoPath, connectionId, localGitOptions),
|
||||
getGitHubApiRepositoryForRemote: (
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
connectionId?: string | null
|
||||
) => getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId),
|
||||
getOriginGitHubApiRepository: async (repoPath: string, connectionId?: string | null) => {
|
||||
const slug = await getOwnerRepoMock(repoPath, connectionId)
|
||||
// Mirror production: dotcom origin slugs come back pinned to github.com.
|
||||
return slug ? { host: 'github.com', ...slug } : slug
|
||||
},
|
||||
getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) =>
|
||||
getIssueOwnerRepoMock(repoPath, connectionId),
|
||||
resolveIssueGitHubApiRepositorySource: async (
|
||||
repoPath: string,
|
||||
_preference: unknown,
|
||||
connectionId?: string | null
|
||||
) => ({ source: await getIssueOwnerRepoMock(repoPath, connectionId), fellBack: false })
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
checkOrcaStarred,
|
||||
getPRComments,
|
||||
@@ -3520,7 +3553,7 @@ describe('getPRForBranch', () => {
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['repo', 'view', 'StablyAI/Orca', '--json', 'isFork,parent'],
|
||||
{ cwd: '/repo-root', timeout: 10_000 }
|
||||
{ cwd: '/repo-root', host: 'github.com', timeout: 10_000 }
|
||||
)
|
||||
})
|
||||
|
||||
@@ -3545,10 +3578,45 @@ describe('getPRForBranch', () => {
|
||||
|
||||
await expect(getRepoUpstream('/repo-root')).resolves.toEqual({
|
||||
owner: 'stablyai',
|
||||
repo: 'orca'
|
||||
repo: 'orca',
|
||||
// Why: fork parents live on the same server as the fork's origin.
|
||||
host: 'github.com'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes GHES push-target probes through the Enterprise host', async () => {
|
||||
const ghes = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
|
||||
candidates: [ghes],
|
||||
headRepo: ghes
|
||||
})
|
||||
getOwnerRepoForRemoteMock.mockResolvedValueOnce(ghes)
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
head: {
|
||||
ref: 'feature',
|
||||
repo: {
|
||||
full_name: 'team/orca',
|
||||
name: 'orca',
|
||||
clone_url: 'https://github.acme-corp.com/team/orca.git',
|
||||
ssh_url: 'git@github.acme-corp.com:team/orca.git',
|
||||
owner: { login: 'team' }
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await expect(getPullRequestPushTarget('/repo-root', 7)).resolves.toEqual({
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature' }
|
||||
})
|
||||
// Why: the candidate probe must pin options.host so the runner targets the
|
||||
// Enterprise server instead of gh's default host.
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['api', 'repos/team/orca/pulls/7'],
|
||||
expect.objectContaining({ host: 'github.acme-corp.com' })
|
||||
)
|
||||
})
|
||||
|
||||
it('probes additional PR repo candidates when the first lookup is not found', async () => {
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
|
||||
candidates: [
|
||||
|
||||
+71
-39
@@ -49,11 +49,6 @@ import {
|
||||
extractExecError,
|
||||
acquire,
|
||||
release,
|
||||
getOwnerRepo,
|
||||
getIssueOwnerRepo,
|
||||
getOwnerRepoForRemote,
|
||||
resolvePRRepositoryCandidates,
|
||||
resolveIssueSource,
|
||||
classifyGhError,
|
||||
classifyListIssuesError,
|
||||
ghRepoExecOptions,
|
||||
@@ -80,10 +75,15 @@ import {
|
||||
} from './gh-cwd-repo-negative-cache'
|
||||
import type { GitHubRepoContext } from './github-repository-identity'
|
||||
import {
|
||||
getGitHubApiRepositoryForRemote,
|
||||
getIssueGitHubApiRepository,
|
||||
getOriginGitHubApiRepository,
|
||||
githubHostExecOptions,
|
||||
githubRepositorySlugArg,
|
||||
githubRepositoryWebHost,
|
||||
resolveGitHubApiRepository,
|
||||
resolveGitHubApiRepositoryCandidates,
|
||||
resolveIssueGitHubApiRepositorySource,
|
||||
type GitHubApiRepository
|
||||
} from './github-api-repository'
|
||||
import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key'
|
||||
@@ -329,7 +329,7 @@ export async function getPullRequestPushTarget(
|
||||
): Promise<PullRequestPushTarget | null> {
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
const ghOptions = ghRepoExecOptions(context)
|
||||
const { candidates } = await resolvePRRepositoryCandidates(
|
||||
const { candidates } = await resolveGitHubApiRepositoryCandidates(
|
||||
repoPath,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
@@ -345,9 +345,7 @@ export async function getPullRequestPushTarget(
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', `repos/${candidate.owner}/${candidate.repo}/pulls/${prNumber}`],
|
||||
{
|
||||
...ghOptions
|
||||
}
|
||||
{ ...ghOptions, ...githubHostExecOptions(candidate) }
|
||||
)
|
||||
prStdout = stdout
|
||||
break
|
||||
@@ -364,7 +362,12 @@ export async function getPullRequestPushTarget(
|
||||
if (!prStdout) {
|
||||
return null
|
||||
}
|
||||
const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions)
|
||||
const origin = await getGitHubApiRepositoryForRemote(
|
||||
repoPath,
|
||||
'origin',
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
const pr = JSON.parse(prStdout) as {
|
||||
maintainer_can_modify?: boolean
|
||||
head?: {
|
||||
@@ -1123,8 +1126,8 @@ async function resolvePrWorkItemSource(
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<ResolvedPrWorkItemSource> {
|
||||
const [originCandidate, upstreamCandidate] = await Promise.all([
|
||||
getOwnerRepo(repoPath, connectionId, localGitOptions),
|
||||
getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions)
|
||||
getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions),
|
||||
getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions)
|
||||
])
|
||||
const source =
|
||||
preference === 'upstream' ? (upstreamCandidate ?? originCandidate) : originCandidate
|
||||
@@ -1195,12 +1198,15 @@ async function listRecentWorkItems(
|
||||
// side, matching the parent design doc's partial-failure rule (§2).
|
||||
const [issuesSettled, prsSettled] = await Promise.allSettled([
|
||||
issueOwnerRepo
|
||||
? ghExecFileAsync(issueRequest.args, ghOptions)
|
||||
? ghExecFileAsync(issueRequest.args, {
|
||||
...ghOptions,
|
||||
...githubHostExecOptions(issueOwnerRepo)
|
||||
})
|
||||
: requiresExplicitRepo
|
||||
? Promise.resolve({ stdout: '[]' })
|
||||
: ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions),
|
||||
prOwnerRepo
|
||||
? ghExecFileAsync(prRequest.args, ghOptions)
|
||||
? ghExecFileAsync(prRequest.args, { ...ghOptions, ...githubHostExecOptions(prOwnerRepo) })
|
||||
: requiresExplicitRepo
|
||||
? Promise.resolve({ stdout: '[]' })
|
||||
: ghCwdResolvedExec(repoContext, prRequest.args, ghOptions)
|
||||
@@ -1228,7 +1234,10 @@ async function listRecentWorkItems(
|
||||
prs = (JSON.parse(prsSettled.value.stdout) as Record<string, unknown>[])
|
||||
.slice(prRequest.offset, prRequest.offset + limit)
|
||||
.map((item) => mapPullRequestWorkItem(item, prOwnerRepo))
|
||||
prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, ghOptions)
|
||||
prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, {
|
||||
...ghOptions,
|
||||
...githubHostExecOptions(prOwnerRepo)
|
||||
})
|
||||
} else {
|
||||
// Why: PR-side failures must preserve the pre-diff behavior of
|
||||
// Promise.all by re-throwing so the rejection propagates up through
|
||||
@@ -1320,7 +1329,10 @@ async function listQueriedWorkItems(
|
||||
})
|
||||
try {
|
||||
const { stdout } = issueOwnerRepo
|
||||
? await ghExecFileAsync(request.args, ghOptions)
|
||||
? await ghExecFileAsync(request.args, {
|
||||
...ghOptions,
|
||||
...githubHostExecOptions(issueOwnerRepo)
|
||||
})
|
||||
: await ghCwdResolvedExec(repoContext, request.args, ghOptions)
|
||||
return {
|
||||
items: (JSON.parse(stdout) as Record<string, unknown>[])
|
||||
@@ -1349,12 +1361,18 @@ async function listQueriedWorkItems(
|
||||
})
|
||||
try {
|
||||
const { stdout } = prOwnerRepo
|
||||
? await ghExecFileAsync(request.args, ghOptions)
|
||||
? await ghExecFileAsync(request.args, {
|
||||
...ghOptions,
|
||||
...githubHostExecOptions(prOwnerRepo)
|
||||
})
|
||||
: await ghCwdResolvedExec(repoContext, request.args, ghOptions)
|
||||
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)
|
||||
const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, {
|
||||
...ghOptions,
|
||||
...githubHostExecOptions(prOwnerRepo)
|
||||
})
|
||||
if (query.state === 'closed') {
|
||||
return hydrated.filter((item) => item.state !== 'merged')
|
||||
}
|
||||
@@ -1396,7 +1414,7 @@ export async function listWorkItems(
|
||||
}
|
||||
}
|
||||
const [issueResolved, prResolved] = await Promise.all([
|
||||
resolveIssueSource(repoPath, preference, connectionId, localGitOptions),
|
||||
resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions),
|
||||
resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions)
|
||||
])
|
||||
const issueOwnerRepo = issueResolved.source
|
||||
@@ -1504,7 +1522,10 @@ async function countWorkItemsForQuery(
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<number> {
|
||||
const searchQ = buildSearchQueryString(ownerRepo, query)
|
||||
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
|
||||
const ghOptions = {
|
||||
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
|
||||
...githubHostExecOptions(ownerRepo)
|
||||
}
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
@@ -1561,7 +1582,7 @@ export async function countWorkItems(
|
||||
return 0
|
||||
}
|
||||
const [issueResolved, prResolved] = await Promise.all([
|
||||
resolveIssueSource(repoPath, preference, connectionId, localGitOptions),
|
||||
resolveIssueGitHubApiRepositorySource(repoPath, preference, connectionId, localGitOptions),
|
||||
resolvePrWorkItemSource(repoPath, preference, connectionId, localGitOptions)
|
||||
])
|
||||
const issueOwnerRepo = issueResolved.source
|
||||
@@ -1658,8 +1679,12 @@ export async function getRepoSlug(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
): Promise<{ owner: string; repo: string } | null> {
|
||||
return getOwnerRepo(repoPath, connectionId, ...hostedReviewLocalGitOptionArgs(options))
|
||||
): Promise<GitHubApiRepository | null> {
|
||||
return getOriginGitHubApiRepository(
|
||||
repoPath,
|
||||
connectionId,
|
||||
getHostedReviewLocalGitOptions(options)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1677,27 +1702,30 @@ export async function getRepoUpstream(
|
||||
): Promise<OwnerRepo | null> {
|
||||
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
|
||||
const localGitOptions = localGitArgs[0] ?? {}
|
||||
const origin = await getOwnerRepo(repoPath, connectionId, ...localGitArgs)
|
||||
const origin = await getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions)
|
||||
if (!origin) {
|
||||
return null
|
||||
}
|
||||
const upstreamRemote = await getOwnerRepoForRemote(
|
||||
const upstreamRemote = await getGitHubApiRepositoryForRemote(
|
||||
repoPath,
|
||||
'upstream',
|
||||
connectionId,
|
||||
...localGitArgs
|
||||
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', `${origin.owner}/${origin.repo}`, '--json', 'isFork,parent'],
|
||||
['repo', 'view', githubRepositorySlugArg(origin), '--json', 'isFork,parent'],
|
||||
// Why: best-effort fork lookup runs at add-time; cap latency so a stalled
|
||||
// gh process can't hold up repo creation.
|
||||
{
|
||||
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
|
||||
...githubHostExecOptions(origin),
|
||||
timeout: 10_000
|
||||
}
|
||||
)
|
||||
@@ -1707,7 +1735,10 @@ export async function getRepoUpstream(
|
||||
}
|
||||
const owner = data.parent?.owner?.login
|
||||
const repo = data.parent?.name
|
||||
return data.isFork && owner && repo ? { owner, repo } : null
|
||||
// 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 {
|
||||
@@ -2015,7 +2046,7 @@ export async function getWorkItem(
|
||||
if (type === 'issue') {
|
||||
return await fetchIssueWorkItem(
|
||||
repoPath,
|
||||
await getIssueOwnerRepo(repoPath, connectionId, localGitOptions),
|
||||
await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions),
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
@@ -2024,7 +2055,7 @@ export async function getWorkItem(
|
||||
if (type === 'pr') {
|
||||
return await fetchPullRequestWorkItem(
|
||||
repoPath,
|
||||
await getOwnerRepo(repoPath, connectionId, localGitOptions),
|
||||
await getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions),
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
@@ -2034,7 +2065,7 @@ export async function getWorkItem(
|
||||
try {
|
||||
const issue = await fetchIssueWorkItem(
|
||||
repoPath,
|
||||
await getIssueOwnerRepo(repoPath, connectionId, localGitOptions),
|
||||
await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions),
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
@@ -2056,7 +2087,7 @@ export async function getWorkItem(
|
||||
}
|
||||
return await fetchPullRequestWorkItem(
|
||||
repoPath,
|
||||
await getOwnerRepo(repoPath, connectionId, localGitOptions),
|
||||
await getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions),
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
@@ -2960,10 +2991,11 @@ export async function getPRForBranch(
|
||||
// Why: the exact-linked fallback (`gh pr view` with no resolved repo candidates)
|
||||
// returns dataRepo=null, which would leave the merged-PR membership probe unable
|
||||
// to run. Derive the PR's own repo from its web URL so a diverged merged linked
|
||||
// PR can still be confirmed and cleared. Host-agnostic to cover GitHub Enterprise.
|
||||
// PR can still be confirmed and cleared. The URL's hostname carries the identity
|
||||
// for GitHub Enterprise, so the probe targets the PR's actual server.
|
||||
function ownerRepoFromPullRequestUrl(url: string): OwnerRepo | null {
|
||||
const match = url.match(/^https?:\/\/[^/\s]+\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/)
|
||||
return match ? { owner: match[1], repo: match[2] } : null
|
||||
const match = url.match(/^https?:\/\/([^/\s]+)\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/)
|
||||
return match ? { owner: match[2], repo: match[3], host: match[1] } : null
|
||||
}
|
||||
|
||||
export async function getPRForBranchOutcome(
|
||||
@@ -2988,10 +3020,10 @@ export async function getPRForBranchOutcome(
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const { candidates, headRepo } = await resolvePRRepositoryCandidates(
|
||||
const { candidates, headRepo } = await resolveGitHubApiRepositoryCandidates(
|
||||
repoPath,
|
||||
connectionId,
|
||||
...localGitArgs
|
||||
localGitOptions
|
||||
)
|
||||
let data: PullRequestLookupData | null = null
|
||||
let dataRepo: OwnerRepo | null = null
|
||||
@@ -3110,11 +3142,11 @@ export async function getPRForBranchOutcome(
|
||||
)
|
||||
if (upstreamBranch) {
|
||||
const upstreamHeadRepo =
|
||||
(await getOwnerRepoForRemote(
|
||||
(await getGitHubApiRepositoryForRemote(
|
||||
repoPath,
|
||||
upstreamBranch.remoteName,
|
||||
connectionId,
|
||||
...localGitArgs
|
||||
localGitOptions
|
||||
)) ?? headRepo
|
||||
if (
|
||||
upstreamHeadRepo &&
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { GitHubOwnerRepo } from '../../shared/types'
|
||||
import { isDefaultGitHubHost } from '../../shared/github-repository-identity-key'
|
||||
import { getOwnerRepo, type LocalGitExecOptions } from './gh-utils'
|
||||
import { getEnterpriseGitHubRepoSlug } from './github-enterprise-repository'
|
||||
import type { GitHubOwnerRepo, IssueSourcePreference } from '../../shared/types'
|
||||
import {
|
||||
githubRepoIdentityKey,
|
||||
isDefaultGitHubHost
|
||||
} from '../../shared/github-repository-identity-key'
|
||||
import { getOwnerRepo, getOwnerRepoForRemote, type LocalGitExecOptions } from './gh-utils'
|
||||
import {
|
||||
getEnterpriseGitHubRepoSlug,
|
||||
getEnterpriseGitHubRepoSlugForRemote
|
||||
} from './github-enterprise-repository'
|
||||
|
||||
export type GitHubApiRepository = GitHubOwnerRepo
|
||||
|
||||
@@ -14,10 +20,11 @@ const originRepoInFlight = new Map<string, Promise<GitHubApiRepository | null>>(
|
||||
|
||||
function originRepoCacheKey(
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): string {
|
||||
return `${connectionId ?? 'local'}\0${localGitOptions.wslDistro ?? ''}\0${repoPath}`
|
||||
return `${connectionId ?? 'local'}\0${localGitOptions.wslDistro ?? ''}\0${repoPath}\0${remoteName}`
|
||||
}
|
||||
|
||||
/** @internal - exposed for tests only */
|
||||
@@ -26,16 +33,27 @@ export function _resetOriginGitHubApiRepositoryCache(): void {
|
||||
originRepoInFlight.clear()
|
||||
}
|
||||
|
||||
export async function getOriginGitHubApiRepository(
|
||||
/**
|
||||
* Host-qualified repository identity for one remote: github.com remotes come
|
||||
* from the cached slug parser; any other GitHub-shaped host is auth-gated so a
|
||||
* non-GitHub forge never routes to the GitHub provider.
|
||||
*/
|
||||
export async function getGitHubApiRepositoryForRemote(
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitHubApiRepository | null> {
|
||||
const ownerRepo = await getOwnerRepo(repoPath, connectionId, localGitOptions)
|
||||
// Why: origin routes through the origin-named entry points so their
|
||||
// call-signature (and the tests mocking them) stay the single seam.
|
||||
const ownerRepo =
|
||||
remoteName === 'origin'
|
||||
? await getOwnerRepo(repoPath, connectionId, localGitOptions)
|
||||
: await getOwnerRepoForRemote(repoPath, remoteName, connectionId, localGitOptions)
|
||||
if (ownerRepo) {
|
||||
return { ...ownerRepo, host: 'github.com' }
|
||||
}
|
||||
const cacheKey = originRepoCacheKey(repoPath, connectionId, localGitOptions)
|
||||
const cacheKey = originRepoCacheKey(repoPath, remoteName, connectionId, localGitOptions)
|
||||
const cached = originRepoCache.get(cacheKey)
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.value
|
||||
@@ -47,7 +65,15 @@ export async function getOriginGitHubApiRepository(
|
||||
const probe = (async () => {
|
||||
const enterpriseOptions =
|
||||
Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : {}
|
||||
const slug = await getEnterpriseGitHubRepoSlug(repoPath, connectionId, enterpriseOptions)
|
||||
const slug =
|
||||
remoteName === 'origin'
|
||||
? await getEnterpriseGitHubRepoSlug(repoPath, connectionId, enterpriseOptions)
|
||||
: await getEnterpriseGitHubRepoSlugForRemote(
|
||||
repoPath,
|
||||
remoteName,
|
||||
connectionId,
|
||||
enterpriseOptions
|
||||
)
|
||||
originRepoCache.set(cacheKey, { value: slug, expiresAt: Date.now() + ORIGIN_REPO_CACHE_TTL_MS })
|
||||
return slug
|
||||
})()
|
||||
@@ -61,6 +87,111 @@ export async function getOriginGitHubApiRepository(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOriginGitHubApiRepository(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitHubApiRepository | null> {
|
||||
return getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions)
|
||||
}
|
||||
|
||||
/** Hosted mirror of getIssueOwnerRepo: issues prefer `upstream` over `origin`. */
|
||||
export async function getIssueGitHubApiRepository(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitHubApiRepository | null> {
|
||||
const upstream = await getGitHubApiRepositoryForRemote(
|
||||
repoPath,
|
||||
'upstream',
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (upstream) {
|
||||
return upstream
|
||||
}
|
||||
return getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions)
|
||||
}
|
||||
|
||||
export type GitHubApiRepositoryCandidates = {
|
||||
candidates: GitHubApiRepository[]
|
||||
headRepo: GitHubApiRepository | null
|
||||
}
|
||||
|
||||
/** Hosted mirror of resolvePRRepositoryCandidates: upstream first, then origin. */
|
||||
export async function resolveGitHubApiRepositoryCandidates(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitHubApiRepositoryCandidates> {
|
||||
const [upstream, origin] = await Promise.all([
|
||||
getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions),
|
||||
getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions)
|
||||
])
|
||||
const seen = new Set<string>()
|
||||
const candidates: GitHubApiRepository[] = []
|
||||
for (const candidate of [upstream, origin]) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
const key = githubRepoIdentityKey(candidate)
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
candidates.push(candidate)
|
||||
}
|
||||
return { candidates, headRepo: origin }
|
||||
}
|
||||
|
||||
export type ResolvedGitHubApiRepositorySource = {
|
||||
source: GitHubApiRepository | null
|
||||
/** True when explicit upstream is gone and resolver fell back to origin. */
|
||||
fellBack: boolean
|
||||
}
|
||||
|
||||
/** Hosted mirror of resolveIssueSource — same preference semantics. */
|
||||
export async function resolveIssueGitHubApiRepositorySource(
|
||||
repoPath: string,
|
||||
preference: IssueSourcePreference | undefined,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<ResolvedGitHubApiRepositorySource> {
|
||||
if (preference === 'upstream') {
|
||||
const upstream = await getGitHubApiRepositoryForRemote(
|
||||
repoPath,
|
||||
'upstream',
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (upstream) {
|
||||
return { source: upstream, fellBack: false }
|
||||
}
|
||||
const origin = await getGitHubApiRepositoryForRemote(
|
||||
repoPath,
|
||||
'origin',
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
return { source: origin, fellBack: origin !== null }
|
||||
}
|
||||
if (preference === 'origin') {
|
||||
return {
|
||||
source: await getGitHubApiRepositoryForRemote(
|
||||
repoPath,
|
||||
'origin',
|
||||
connectionId,
|
||||
localGitOptions
|
||||
),
|
||||
fellBack: false
|
||||
}
|
||||
}
|
||||
return {
|
||||
source: await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions),
|
||||
fellBack: false
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveGitHubApiRepository(
|
||||
repoPath: string,
|
||||
repository?: GitHubApiRepository | null,
|
||||
@@ -104,3 +235,15 @@ export function githubHostExecOptions(repository: GitHubApiRepository | null | u
|
||||
export function githubRepositoryWebHost(repository: GitHubApiRepository): string {
|
||||
return repository.host ?? 'github.com'
|
||||
}
|
||||
|
||||
/**
|
||||
* Positional `HOST/OWNER/REPO` argv value (e.g. `gh repo view <slug>`).
|
||||
* Positional slugs bypass the runner's `--repo` qualifier, so they must be
|
||||
* qualified here; github.com stays bare for compatibility with gh's default.
|
||||
*/
|
||||
export function githubRepositorySlugArg(repository: GitHubApiRepository): string {
|
||||
const slug = `${repository.owner}/${repository.repo}`
|
||||
return repository.host && !isDefaultGitHubHost(repository.host)
|
||||
? `${repository.host}/${slug}`
|
||||
: slug
|
||||
}
|
||||
|
||||
@@ -120,14 +120,15 @@ export async function isGitHubHostAuthenticated(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve owner/repo for a GitHub Enterprise Server `origin` remote — a custom
|
||||
* host the user is gh-authenticated to. Returns null for github.com (already
|
||||
* handled by {@link getOwnerRepo}) and for hosts gh is not logged in to
|
||||
* Resolve owner/repo for a GitHub Enterprise Server remote — a custom host the
|
||||
* user is gh-authenticated to. Returns null for github.com (already handled by
|
||||
* {@link getOwnerRepo}) and for hosts gh is not logged in to
|
||||
* (Gitea/Forgejo/self-hosted GitLab/etc.), so GHES routes to the GitHub provider
|
||||
* without a GitHub provider stealing another forge's remote.
|
||||
*/
|
||||
export async function getEnterpriseGitHubRepoSlug(
|
||||
export async function getEnterpriseGitHubRepoSlugForRemote(
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
): Promise<GitHubEnterpriseRepoSlug | null> {
|
||||
@@ -135,7 +136,7 @@ export async function getEnterpriseGitHubRepoSlug(
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
let remoteUrl: string | null
|
||||
try {
|
||||
remoteUrl = await getRemoteUrlForRepo(context, 'origin')
|
||||
remoteUrl = await getRemoteUrlForRepo(context, remoteName)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -151,3 +152,11 @@ export async function getEnterpriseGitHubRepoSlug(
|
||||
)
|
||||
return authenticated ? { owner: identity.owner, repo: identity.repo, host: identity.host } : null
|
||||
}
|
||||
|
||||
export async function getEnterpriseGitHubRepoSlug(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
): Promise<GitHubEnterpriseRepoSlug | null> {
|
||||
return getEnterpriseGitHubRepoSlugForRemote(repoPath, 'origin', connectionId, options)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GithubApiRepositoryModule from './github-api-repository'
|
||||
import type * as GhUtils from './gh-utils'
|
||||
|
||||
const {
|
||||
@@ -27,6 +28,26 @@ vi.mock('./gh-utils', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./github-api-repository', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof GithubApiRepositoryModule>()
|
||||
return {
|
||||
...actual,
|
||||
// Why: these suites drive source resolution through the legacy gh-utils
|
||||
// mocks; bridge the hosted seams onto the same mocks.
|
||||
getIssueGitHubApiRepository: (
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) => getIssueOwnerRepoMock(repoPath, connectionId, localGitOptions),
|
||||
resolveIssueGitHubApiRepositorySource: (
|
||||
repoPath: string,
|
||||
preference: unknown,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions)
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
addIssueComment,
|
||||
createIssue,
|
||||
|
||||
+20
-15
@@ -15,9 +15,14 @@ import type {
|
||||
} from '../../shared/types'
|
||||
import { mapIssueInfo } from './mappers'
|
||||
import type { LocalGitExecOptions, OwnerRepo } from './gh-utils'
|
||||
import { githubHostExecOptions, resolveGitHubApiRepository } from './github-api-repository'
|
||||
import {
|
||||
getIssueGitHubApiRepository,
|
||||
githubHostExecOptions,
|
||||
resolveGitHubApiRepository,
|
||||
resolveIssueGitHubApiRepositorySource
|
||||
} from './github-api-repository'
|
||||
// prettier-ignore
|
||||
import { ghExecFileAsync, acquire, release, getIssueOwnerRepo, resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, githubRepoContext, extractExecError } from './gh-utils'
|
||||
import { ghExecFileAsync, acquire, release, classifyGhError, classifyListIssuesError, ghRepoExecOptions, githubRepoContext, extractExecError } from './gh-utils'
|
||||
|
||||
// Why: distinguishes a successful-empty listing from a failed fetch. The
|
||||
// previous `catch { return [] }` conflated a 403 on a private upstream with an
|
||||
@@ -56,8 +61,8 @@ export async function getIssue(
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<IssueInfo | null> {
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
const ghOptions = ghRepoExecOptions(context)
|
||||
const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId, localGitOptions)
|
||||
const ownerRepo = await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)
|
||||
const ghOptions = { ...ghRepoExecOptions(context), ...githubHostExecOptions(ownerRepo) }
|
||||
await acquire()
|
||||
try {
|
||||
if (ownerRepo) {
|
||||
@@ -106,13 +111,13 @@ export async function listIssues(
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<IssueListResult> {
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
const ghOptions = ghRepoExecOptions(context)
|
||||
const { source: ownerRepo } = await resolveIssueSource(
|
||||
const { source: ownerRepo } = await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
const ghOptions = { ...ghRepoExecOptions(context), ...githubHostExecOptions(ownerRepo) }
|
||||
await acquire()
|
||||
try {
|
||||
if (ownerRepo) {
|
||||
@@ -175,13 +180,13 @@ export async function createIssue(
|
||||
return { ok: false, error: 'Title is required' }
|
||||
}
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
const ghOptions = ghRepoExecOptions(context)
|
||||
const { source: ownerRepo } = await resolveIssueSource(
|
||||
const { source: ownerRepo } = await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
const ghOptions = { ...ghRepoExecOptions(context), ...githubHostExecOptions(ownerRepo) }
|
||||
if (!ownerRepo) {
|
||||
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
|
||||
}
|
||||
@@ -288,11 +293,11 @@ export async function updateIssue(
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
const ghOptions = ghRepoExecOptions(context)
|
||||
const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId, localGitOptions)
|
||||
const ownerRepo = await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)
|
||||
if (!ownerRepo) {
|
||||
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
|
||||
}
|
||||
const ghOptions = { ...ghRepoExecOptions(context), ...githubHostExecOptions(ownerRepo) }
|
||||
|
||||
const repo = `${ownerRepo.owner}/${ownerRepo.repo}`
|
||||
const errors: string[] = []
|
||||
@@ -413,7 +418,7 @@ export async function addIssueComment(
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
const ownerRepo = ownerRepoOverride
|
||||
? await resolveGitHubApiRepository(repoPath, ownerRepoOverride, connectionId, localGitOptions)
|
||||
: await getIssueOwnerRepo(repoPath, connectionId, localGitOptions)
|
||||
: await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)
|
||||
if (!ownerRepo) {
|
||||
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
|
||||
}
|
||||
@@ -466,13 +471,13 @@ export async function listLabels(
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<string[]> {
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
const ghOptions = ghRepoExecOptions(context)
|
||||
const { source: ownerRepo } = await resolveIssueSource(
|
||||
const { source: ownerRepo } = await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
const ghOptions = { ...ghRepoExecOptions(context), ...githubHostExecOptions(ownerRepo) }
|
||||
if (!ownerRepo) {
|
||||
return []
|
||||
}
|
||||
@@ -506,13 +511,13 @@ export async function listAssignableUsers(
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitHubAssignableUser[]> {
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
const ghOptions = ghRepoExecOptions(context)
|
||||
const { source: ownerRepo } = await resolveIssueSource(
|
||||
const { source: ownerRepo } = await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
const ghOptions = { ...ghRepoExecOptions(context), ...githubHostExecOptions(ownerRepo) }
|
||||
if (!ownerRepo) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -152,6 +152,21 @@ describe('parseProjectPaste', () => {
|
||||
expect(parseProjectPaste('https://github.com/orgs/co_op/projects/1')).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts enterprise-host URLs only when that host is provided (GHES)', () => {
|
||||
const url = 'https://github.corp.example/orgs/acme/projects/7/views/2'
|
||||
expect(parseProjectPaste(url, 'github.corp.example')).toEqual({
|
||||
kind: 'org',
|
||||
owner: 'acme',
|
||||
number: 7,
|
||||
viewNumber: 2
|
||||
})
|
||||
expect(parseProjectPaste(url)).toBeNull()
|
||||
// github.com URLs still parse when a GHES host is supplied.
|
||||
expect(
|
||||
parseProjectPaste('https://github.com/orgs/acme/projects/7', 'github.corp.example')
|
||||
).toEqual({ kind: 'org', owner: 'acme', number: 7 })
|
||||
})
|
||||
|
||||
it('returns null for empty input', () => {
|
||||
expect(parseProjectPaste('')).toBeNull()
|
||||
expect(parseProjectPaste(' ')).toBeNull()
|
||||
|
||||
+104
-55
@@ -9,19 +9,22 @@ import {
|
||||
release,
|
||||
extractExecError,
|
||||
ghExecFileAsync,
|
||||
rateLimitGuard,
|
||||
noteRateLimitSpend,
|
||||
classifyProjectError,
|
||||
driftError,
|
||||
errorsIndicateParentField,
|
||||
rateLimitedError,
|
||||
repositoryRateLimitGuard,
|
||||
noteRepositoryRateLimitSpend,
|
||||
runGraphql,
|
||||
isValidOwnerSlug,
|
||||
assertSlug,
|
||||
assertPositiveInt,
|
||||
type GhGraphqlErrorShape,
|
||||
projectGhExecOptions,
|
||||
type GraphqlVars
|
||||
} from './project-view/internals'
|
||||
import {
|
||||
classifyProjectError,
|
||||
driftError,
|
||||
errorsIndicateParentField,
|
||||
rateLimitedError,
|
||||
type GhGraphqlErrorShape
|
||||
} from './project-view/project-error-classification'
|
||||
import type {
|
||||
GetProjectViewTableArgs,
|
||||
GetProjectViewTableResult,
|
||||
@@ -41,6 +44,7 @@ import type {
|
||||
GitHubProjectViewError,
|
||||
GitHubProjectViewLayout,
|
||||
GitHubProjectViewSummary,
|
||||
ListAccessibleProjectsArgs,
|
||||
ListAccessibleProjectsResult,
|
||||
ListProjectViewsArgs,
|
||||
ListProjectViewsResult,
|
||||
@@ -54,12 +58,8 @@ import {
|
||||
|
||||
// Re-export the public API so existing call sites (`./project-view`) keep
|
||||
// working unchanged. The split is internal-only.
|
||||
export {
|
||||
isValidOwnerSlug,
|
||||
isValidRepoSlug,
|
||||
isValidSlug,
|
||||
classifyProjectError
|
||||
} from './project-view/internals'
|
||||
export { isValidOwnerSlug, isValidRepoSlug, isValidSlug } from './project-view/internals'
|
||||
export { classifyProjectError } from './project-view/project-error-classification'
|
||||
export {
|
||||
updateProjectItemFieldValue,
|
||||
clearProjectItemFieldValue,
|
||||
@@ -145,16 +145,32 @@ const parentFieldWarningLoggedByOwner = new Map<string, true>()
|
||||
// one caller drives the probe; siblings await the same result.
|
||||
const parentFieldProbeInFlight = new Map<string, Promise<void>>()
|
||||
|
||||
function ownerScopeKey(owner: string, ownerType: GitHubProjectOwnerType): string {
|
||||
return `${owner}\u0000${ownerType}`
|
||||
// Why: GHES owners are a separate namespace and capability surface from
|
||||
// github.com owners with the same login — scope cache keys by host so one
|
||||
// host's probe result can't leak into another. Host-less keys keep the
|
||||
// legacy shape so existing entries/tests are unaffected.
|
||||
function ownerScopeKey(owner: string, ownerType: GitHubProjectOwnerType, host?: string): string {
|
||||
const base = `${owner}\u0000${ownerType}`
|
||||
return host ? `${base}\u0000${host}` : base
|
||||
}
|
||||
|
||||
function rememberOwnerType(owner: string, ownerType: GitHubProjectOwnerType | null): void {
|
||||
rememberProjectViewCacheEntry(ownerTypeCache, owner, ownerType)
|
||||
function ownerTypeCacheKey(owner: string, host?: string): string {
|
||||
return host ? `${owner}\u0000${host}` : owner
|
||||
}
|
||||
|
||||
function getCachedOwnerType(owner: string): GitHubProjectOwnerType | null | undefined {
|
||||
return getProjectViewCacheEntry(ownerTypeCache, owner)
|
||||
function rememberOwnerType(
|
||||
owner: string,
|
||||
ownerType: GitHubProjectOwnerType | null,
|
||||
host?: string
|
||||
): void {
|
||||
rememberProjectViewCacheEntry(ownerTypeCache, ownerTypeCacheKey(owner, host), ownerType)
|
||||
}
|
||||
|
||||
function getCachedOwnerType(
|
||||
owner: string,
|
||||
host?: string
|
||||
): GitHubProjectOwnerType | null | undefined {
|
||||
return getProjectViewCacheEntry(ownerTypeCache, ownerTypeCacheKey(owner, host))
|
||||
}
|
||||
|
||||
function markParentFieldRetried(scopeKey: string): void {
|
||||
@@ -197,16 +213,18 @@ export function _getProjectViewCacheSizesForTests(): {
|
||||
/** @internal - exposed for cache-bound tests only. */
|
||||
export function _rememberProjectViewOwnerTypeForTests(
|
||||
owner: string,
|
||||
ownerType: GitHubProjectOwnerType | null
|
||||
ownerType: GitHubProjectOwnerType | null,
|
||||
host?: string
|
||||
): void {
|
||||
rememberOwnerType(owner, ownerType)
|
||||
rememberOwnerType(owner, ownerType, host)
|
||||
}
|
||||
|
||||
/** @internal - exposed for cache-bound tests only. */
|
||||
export function _getProjectViewOwnerTypeForTests(
|
||||
owner: string
|
||||
owner: string,
|
||||
host?: string
|
||||
): GitHubProjectOwnerType | null | undefined {
|
||||
return getCachedOwnerType(owner)
|
||||
return getCachedOwnerType(owner, host)
|
||||
}
|
||||
|
||||
/** @internal - exposed for cache-bound tests only. */
|
||||
@@ -642,6 +660,7 @@ async function fetchProjectViewsPage(args: {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
host?: string
|
||||
after: string | null
|
||||
}): Promise<
|
||||
| {
|
||||
@@ -686,7 +705,8 @@ async function fetchProjectViewsPage(args: {
|
||||
}
|
||||
const res = await runGraphql<Record<string, { projectV2?: RawProjectConfig | null } | null>>(
|
||||
query,
|
||||
vars
|
||||
vars,
|
||||
projectGhExecOptions(args.host)
|
||||
)
|
||||
if (!res.ok) {
|
||||
return res
|
||||
@@ -709,7 +729,8 @@ async function fetchProjectViewsPage(args: {
|
||||
|
||||
async function fetchViewFieldsContinuation(
|
||||
viewId: string,
|
||||
after: string
|
||||
after: string,
|
||||
host?: string
|
||||
): Promise<
|
||||
{ ok: true; fields: RawProjectV2Field[] } | { ok: false; error: GitHubProjectViewError }
|
||||
> {
|
||||
@@ -745,7 +766,7 @@ async function fetchViewFieldsContinuation(
|
||||
nodes?: (RawProjectV2Field | null)[]
|
||||
}
|
||||
} | null
|
||||
}>(query, { viewId, after: cursor })
|
||||
}>(query, { viewId, after: cursor }, projectGhExecOptions(host))
|
||||
if (!res.ok) {
|
||||
return res
|
||||
}
|
||||
@@ -855,6 +876,7 @@ async function fetchItemsPageWithRaw(args: {
|
||||
first: number
|
||||
after: string | null
|
||||
includeParent: boolean
|
||||
host?: string
|
||||
}): Promise<
|
||||
| { ok: true; page: RawItemsPage }
|
||||
| {
|
||||
@@ -896,7 +918,9 @@ async function fetchItemsPageWithRaw(args: {
|
||||
argsArr.push('-f', `after=${args.after}`)
|
||||
}
|
||||
|
||||
const guard = rateLimitGuard('graphql')
|
||||
// Why: GHES traffic runs against its own quota — only github.com requests
|
||||
// consult/debit the shared snapshot.
|
||||
const guard = repositoryRateLimitGuard(args, 'graphql')
|
||||
if (guard.blocked) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -906,13 +930,16 @@ async function fetchItemsPageWithRaw(args: {
|
||||
}
|
||||
}
|
||||
await acquire()
|
||||
noteRateLimitSpend('graphql')
|
||||
noteRepositoryRateLimitSpend(args, 'graphql')
|
||||
try {
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let execFailed = false
|
||||
try {
|
||||
const r = await ghExecFileAsync(argsArr, { encoding: 'utf-8' })
|
||||
const r = await ghExecFileAsync(argsArr, {
|
||||
encoding: 'utf-8',
|
||||
...projectGhExecOptions(args.host)
|
||||
})
|
||||
stdout = r.stdout
|
||||
stderr = r.stderr
|
||||
} catch (err) {
|
||||
@@ -983,6 +1010,7 @@ async function fetchAllItems(args: {
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
query: string
|
||||
host?: string
|
||||
}): Promise<
|
||||
| { ok: true; rows: GitHubProjectRow[]; totalCount: number; parentFieldDropped: boolean }
|
||||
| { ok: false; error: GitHubProjectViewError; totalCount?: number }
|
||||
@@ -990,7 +1018,7 @@ async function fetchAllItems(args: {
|
||||
// Why: caches keyed by (owner, ownerType) so a single owner's lack of
|
||||
// Issue.parent capability (e.g. token without scope on org A) doesn't
|
||||
// poison fetches for unrelated owners. See bug-scan finding 2.
|
||||
const scopeKey = ownerScopeKey(args.owner, args.ownerType)
|
||||
const scopeKey = ownerScopeKey(args.owner, args.ownerType, args.host)
|
||||
// Why: if another caller for the SAME owner is currently probing whether
|
||||
// Issue.parent is supported, await its decision so we don't fire a
|
||||
// duplicate with-parent probe. We must re-read the retried flag AFTER
|
||||
@@ -1024,7 +1052,8 @@ async function fetchAllItems(args: {
|
||||
query: args.query,
|
||||
first: ITEM_PAGE_SIZE,
|
||||
after: null,
|
||||
includeParent: true
|
||||
includeParent: true,
|
||||
host: args.host
|
||||
})
|
||||
// Why: flip parentFieldRetriedByOwner BEFORE resolveProbe()/clearing
|
||||
// parentFieldProbeInFlight so siblings that awoke on `inFlight.catch()`
|
||||
@@ -1050,7 +1079,8 @@ async function fetchAllItems(args: {
|
||||
query: args.query,
|
||||
first: ITEM_PAGE_SIZE,
|
||||
after: null,
|
||||
includeParent
|
||||
includeParent,
|
||||
host: args.host
|
||||
})
|
||||
}
|
||||
if (!first.ok && includeParent && errorsIndicateParentField(first.rawErrors, first.stderr)) {
|
||||
@@ -1073,7 +1103,8 @@ async function fetchAllItems(args: {
|
||||
query: args.query,
|
||||
first: ITEM_PAGE_SIZE,
|
||||
after: null,
|
||||
includeParent: false
|
||||
includeParent: false,
|
||||
host: args.host
|
||||
})
|
||||
}
|
||||
if (!first.ok) {
|
||||
@@ -1140,7 +1171,8 @@ async function fetchAllItems(args: {
|
||||
query: args.query,
|
||||
first: ITEM_PAGE_SIZE,
|
||||
after: cursor as string,
|
||||
includeParent
|
||||
includeParent,
|
||||
host: args.host
|
||||
})
|
||||
if (!next.ok) {
|
||||
return { ok: false, error: next.error, totalCount }
|
||||
@@ -1179,6 +1211,7 @@ async function fetchItemsCountOnly(args: {
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
query: string
|
||||
host?: string
|
||||
}): Promise<number | null> {
|
||||
const root = ownerQueryRoot(args.ownerType)
|
||||
const query = `
|
||||
@@ -1192,7 +1225,11 @@ async function fetchItemsCountOnly(args: {
|
||||
`
|
||||
const res = await runGraphql<
|
||||
Record<string, { projectV2?: { items?: { totalCount?: number } | null } | null } | null>
|
||||
>(query, { owner: args.owner, num: args.projectNumber, q: args.query })
|
||||
>(
|
||||
query,
|
||||
{ owner: args.owner, num: args.projectNumber, q: args.query },
|
||||
projectGhExecOptions(args.host)
|
||||
)
|
||||
if (!res.ok) {
|
||||
return null
|
||||
}
|
||||
@@ -1231,6 +1268,7 @@ export async function getProjectViewTable(
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
host: args.host,
|
||||
after: cursor
|
||||
})
|
||||
if (!page.ok) {
|
||||
@@ -1287,7 +1325,7 @@ export async function getProjectViewTable(
|
||||
let extraFields: RawProjectV2Field[] = []
|
||||
const fieldsPi = selectedRaw.fields?.pageInfo
|
||||
if (fieldsPi?.hasNextPage === true && typeof fieldsPi.endCursor === 'string' && selectedRaw.id) {
|
||||
const cont = await fetchViewFieldsContinuation(selectedRaw.id, fieldsPi.endCursor)
|
||||
const cont = await fetchViewFieldsContinuation(selectedRaw.id, fieldsPi.endCursor, args.host)
|
||||
if (!cont.ok) {
|
||||
return { ok: false, error: cont.error }
|
||||
}
|
||||
@@ -1314,7 +1352,8 @@ export async function getProjectViewTable(
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
query: effectiveQuery
|
||||
query: effectiveQuery,
|
||||
host: args.host
|
||||
})
|
||||
return {
|
||||
ok: false,
|
||||
@@ -1331,7 +1370,8 @@ export async function getProjectViewTable(
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
query: effectiveQuery
|
||||
query: effectiveQuery,
|
||||
host: args.host
|
||||
})
|
||||
if (!items.ok) {
|
||||
return {
|
||||
@@ -1386,7 +1426,10 @@ type RawViewerDiscovery = {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAccessibleProjects(): Promise<ListAccessibleProjectsResult> {
|
||||
export async function listAccessibleProjects(
|
||||
args?: ListAccessibleProjectsArgs
|
||||
): Promise<ListAccessibleProjectsResult> {
|
||||
const host = args?.host
|
||||
const viewerProjects: GitHubProjectSummary[] = []
|
||||
const orgProjects: GitHubProjectSummary[] = []
|
||||
// Why: per-org failures are collected so the picker can render a "some orgs
|
||||
@@ -1421,7 +1464,7 @@ export async function listAccessibleProjects(): Promise<ListAccessibleProjectsRe
|
||||
if (viewerCursor) {
|
||||
vars.after = viewerCursor
|
||||
}
|
||||
const res = await runGraphql<RawViewerDiscovery>(query, vars)
|
||||
const res = await runGraphql<RawViewerDiscovery>(query, vars, projectGhExecOptions(host))
|
||||
if (!res.ok) {
|
||||
// Why: a viewer-level failure is structural — if we can't list the
|
||||
// user's own projects, we have nothing to build on. Propagate as a
|
||||
@@ -1496,7 +1539,7 @@ export async function listAccessibleProjects(): Promise<ListAccessibleProjectsRe
|
||||
if (orgCursor) {
|
||||
vars.orgAfter = orgCursor
|
||||
}
|
||||
const res = await runGraphql<RawViewerDiscovery>(query, vars)
|
||||
const res = await runGraphql<RawViewerDiscovery>(query, vars, projectGhExecOptions(host))
|
||||
if (!res.ok) {
|
||||
// Why: the org-listing query itself failed (not a nested projectsV2).
|
||||
// Record it as a partial failure against a synthetic `*` owner so the
|
||||
@@ -1519,7 +1562,7 @@ export async function listAccessibleProjects(): Promise<ListAccessibleProjectsRe
|
||||
// Cache owner → ownerType for downstream paste/resolve even when the
|
||||
// nested projects query was empty or partially failed — paste-to-add
|
||||
// uses this to disambiguate /orgs/ vs /users/ URLs.
|
||||
rememberOwnerType(login, 'organization')
|
||||
rememberOwnerType(login, 'organization', host)
|
||||
const nodes = org.projectsV2?.nodes ?? []
|
||||
let ownerCount = 0
|
||||
for (const n of nodes) {
|
||||
@@ -1547,7 +1590,7 @@ export async function listAccessibleProjects(): Promise<ListAccessibleProjectsRe
|
||||
}
|
||||
|
||||
if (viewerLogin) {
|
||||
rememberOwnerType(viewerLogin, 'user')
|
||||
rememberOwnerType(viewerLogin, 'user', host)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1564,7 +1607,7 @@ type ParsedPaste =
|
||||
| { kind: 'user'; owner: string; number: number; viewNumber?: number }
|
||||
| { kind: 'bare'; owner: string; number: number }
|
||||
|
||||
export function parseProjectPaste(input: string): ParsedPaste | null {
|
||||
export function parseProjectPaste(input: string, host?: string): ParsedPaste | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
@@ -1572,9 +1615,13 @@ export function parseProjectPaste(input: string): ParsedPaste | null {
|
||||
if (isGitHubProjectRefInputTooLarge(trimmed)) {
|
||||
return null
|
||||
}
|
||||
// URL forms
|
||||
const urlRe =
|
||||
/^https?:\/\/github\.com\/(orgs|users)\/([^/]+)\/projects\/(\d+)(?:\/views\/(\d+))?/i
|
||||
// URL forms. Why: GHES project URLs live on the enterprise host — accept it
|
||||
// alongside github.com when the caller supplies one.
|
||||
const escapedHost = host ? host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : null
|
||||
const urlRe = new RegExp(
|
||||
`^https?://(?:github\\.com${escapedHost ? `|${escapedHost}` : ''})/(orgs|users)/([^/]+)/projects/(\\d+)(?:/views/(\\d+))?`,
|
||||
'i'
|
||||
)
|
||||
const m = trimmed.match(urlRe)
|
||||
if (m) {
|
||||
const [, kindSeg, owner, nStr, vStr] = m
|
||||
@@ -1610,7 +1657,8 @@ export function parseProjectPaste(input: string): ParsedPaste | null {
|
||||
|
||||
async function resolveOwnerType(
|
||||
owner: string,
|
||||
preferred: GitHubProjectOwnerType | null
|
||||
preferred: GitHubProjectOwnerType | null,
|
||||
host?: string
|
||||
): Promise<
|
||||
| { ok: true; ownerType: GitHubProjectOwnerType; title: string }
|
||||
| { ok: false; error: GitHubProjectViewError }
|
||||
@@ -1638,7 +1686,7 @@ async function resolveOwnerType(
|
||||
}
|
||||
const res = await runGraphql<
|
||||
Record<string, { projectV2?: { id?: string; title?: string } | null; login?: string } | null>
|
||||
>(query, vars)
|
||||
>(query, vars, projectGhExecOptions(host))
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: res.error }
|
||||
}
|
||||
@@ -1656,7 +1704,7 @@ async function resolveOwnerType(
|
||||
return { ok: true, title: '' }
|
||||
}
|
||||
|
||||
const cached = getCachedOwnerType(owner)
|
||||
const cached = getCachedOwnerType(owner, host)
|
||||
const candidates: GitHubProjectOwnerType[] = preferred
|
||||
? [preferred]
|
||||
: cached
|
||||
@@ -1674,7 +1722,7 @@ async function resolveOwnerType(
|
||||
for (const ot of ordered) {
|
||||
const r = await tryOne(ot, null)
|
||||
if (r.ok) {
|
||||
rememberOwnerType(owner, ot)
|
||||
rememberOwnerType(owner, ot, host)
|
||||
return { ok: true, ownerType: ot, title: r.title }
|
||||
}
|
||||
lastError = r.error
|
||||
@@ -1683,7 +1731,7 @@ async function resolveOwnerType(
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
}
|
||||
rememberOwnerType(owner, null)
|
||||
rememberOwnerType(owner, null, host)
|
||||
return {
|
||||
ok: false,
|
||||
error: lastError ?? { type: 'not_found', message: 'Owner not found.' }
|
||||
@@ -1706,7 +1754,7 @@ export async function resolveProjectRef(
|
||||
error: { type: 'validation_error', message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR }
|
||||
}
|
||||
}
|
||||
const parsed = parseProjectPaste(input)
|
||||
const parsed = parseProjectPaste(input, args.host)
|
||||
if (!parsed) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -1719,7 +1767,7 @@ export async function resolveProjectRef(
|
||||
const preferred: GitHubProjectOwnerType | null =
|
||||
parsed.kind === 'org' ? 'organization' : parsed.kind === 'user' ? 'user' : null
|
||||
// Verify by fetching project title.
|
||||
const ownerRes = await resolveOwnerType(parsed.owner, preferred)
|
||||
const ownerRes = await resolveOwnerType(parsed.owner, preferred, args.host)
|
||||
if (!ownerRes.ok) {
|
||||
return { ok: false, error: ownerRes.error }
|
||||
}
|
||||
@@ -1732,7 +1780,7 @@ export async function resolveProjectRef(
|
||||
`
|
||||
const res = await runGraphql<
|
||||
Record<string, { projectV2?: { id?: string; title?: string } | null } | null>
|
||||
>(query, { owner: parsed.owner, num: parsed.number })
|
||||
>(query, { owner: parsed.owner, num: parsed.number }, projectGhExecOptions(args.host))
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: res.error }
|
||||
}
|
||||
@@ -1778,6 +1826,7 @@ export async function listProjectViews(
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
host: args.host,
|
||||
after: cursor
|
||||
})
|
||||
if (!page.ok) {
|
||||
|
||||
@@ -4,12 +4,43 @@
|
||||
// translation, and a single hook point for future quota tracking.
|
||||
import { acquire, release } from '../gh-utils'
|
||||
import { extractExecError, ghExecFileAsync } from '../../git/runner'
|
||||
import { rateLimitGuard, noteRateLimitSpend, type RateLimitBucketKind } from '../rate-limit'
|
||||
import {
|
||||
rateLimitGuard,
|
||||
noteRateLimitSpend,
|
||||
repositoryRateLimitGuard,
|
||||
noteRepositoryRateLimitSpend,
|
||||
type RateLimitBucketKind
|
||||
} from '../rate-limit'
|
||||
import type { GitHubProjectViewError } from '../../../shared/github-project-types'
|
||||
import {
|
||||
classifyProjectError,
|
||||
driftError,
|
||||
rateLimitedError,
|
||||
type GhGraphqlErrorShape
|
||||
} from './project-error-classification'
|
||||
|
||||
export { acquire, release, extractExecError, ghExecFileAsync, rateLimitGuard, noteRateLimitSpend }
|
||||
export {
|
||||
acquire,
|
||||
release,
|
||||
extractExecError,
|
||||
ghExecFileAsync,
|
||||
rateLimitGuard,
|
||||
noteRateLimitSpend,
|
||||
repositoryRateLimitGuard,
|
||||
noteRepositoryRateLimitSpend
|
||||
}
|
||||
export type { RateLimitBucketKind }
|
||||
|
||||
/** gh exec routing shared by the project-view read/write paths. `host` pins
|
||||
* GHES requests to the enterprise server via the runner's host qualifier. */
|
||||
export type ProjectGhExecOptions = { cwd?: string; host?: string }
|
||||
|
||||
// Why: owner/project-addressed calls carry only a host (no repo slug), so they
|
||||
// can't reuse githubHostExecOptions — mirror its host pinning for that shape.
|
||||
export function projectGhExecOptions(host: string | undefined): ProjectGhExecOptions {
|
||||
return host ? { host } : {}
|
||||
}
|
||||
|
||||
// ─── Slug validation ──────────────────────────────────────────────────
|
||||
|
||||
// Why: GitHub usernames/org logins disallow `_`, `.`, leading `-`. Repo names
|
||||
@@ -88,176 +119,6 @@ export function validateSlugArgs(
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// ─── Error classification ──────────────────────────────────────────────
|
||||
|
||||
export type GhGraphqlErrorShape = {
|
||||
type?: string
|
||||
message?: string
|
||||
path?: (string | number)[]
|
||||
extensions?: { code?: string }
|
||||
}
|
||||
|
||||
export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlErrorShape[] {
|
||||
// `gh api graphql` prints the response JSON to stdout even on GraphQL
|
||||
// errors, and the stderr carries a summary. Try stdout first; if parsing
|
||||
// fails, fall back to stderr.
|
||||
const sources = [stdout, stderr]
|
||||
for (const src of sources) {
|
||||
if (!src) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(src) as { errors?: GhGraphqlErrorShape[] }
|
||||
if (parsed.errors && parsed.errors.length > 0) {
|
||||
return parsed.errors
|
||||
}
|
||||
} catch {
|
||||
// not JSON — continue
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function errorsIndicateParentField(errors: GhGraphqlErrorShape[], stderr: string): boolean {
|
||||
const lower = stderr.toLowerCase()
|
||||
// Preview-header shape: gh returns a 4xx with "preview" in the message.
|
||||
if (lower.includes('preview') && lower.includes('parent')) {
|
||||
return true
|
||||
}
|
||||
return errors.some((e) => {
|
||||
const type = (e.type ?? '').toUpperCase()
|
||||
if (type === 'FIELD_NOT_FOUND' || type === 'UNDEFINED_FIELD' || type === 'FIELD_ERRORS') {
|
||||
const tail = e.path?.at(-1)
|
||||
if (tail === 'parent') {
|
||||
return true
|
||||
}
|
||||
// FIELD_ERRORS often omits `path`; match on message for the parent field.
|
||||
if ((e.message ?? '').toLowerCase().includes('parent')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
export function classifyProjectError(stderr: string, stdout: string): GitHubProjectViewError {
|
||||
const errors = extractGraphqlErrors(stderr, stdout)
|
||||
const s = stderr.toLowerCase()
|
||||
|
||||
// Auth
|
||||
if (
|
||||
s.includes('authentication required') ||
|
||||
s.includes('not logged in') ||
|
||||
s.includes('gh auth login')
|
||||
) {
|
||||
return {
|
||||
type: 'auth_required',
|
||||
message: 'Sign in to GitHub to load project tasks. Run `gh auth login`.'
|
||||
}
|
||||
}
|
||||
// Scope
|
||||
if (
|
||||
s.includes('missing required scope') ||
|
||||
s.includes('your token has not been granted') ||
|
||||
(s.includes('resource not accessible') && (s.includes('project') || s.includes('scope')))
|
||||
) {
|
||||
return {
|
||||
type: 'scope_missing',
|
||||
message:
|
||||
'GitHub project access needs additional scopes. Run `gh auth refresh -s project -s read:org -s repo`.'
|
||||
}
|
||||
}
|
||||
// Rate limit
|
||||
if (s.includes('rate limit') || s.includes('api rate limit exceeded')) {
|
||||
return { type: 'rate_limited', message: 'GitHub rate limit hit. Try again in a few minutes.' }
|
||||
}
|
||||
// Network — checked BEFORE not_found because DNS failures surface as
|
||||
// "could not resolve host", which would otherwise be partially matched by
|
||||
// the not_found branch's "could not resolve" check. Substring matching here
|
||||
// is a one-way trapdoor: a real GraphQL "Could not resolve to a User…"
|
||||
// error always contains "to a", so we tighten the not_found check below to
|
||||
// require that token.
|
||||
if (
|
||||
s.includes('timeout') ||
|
||||
s.includes('no such host') ||
|
||||
s.includes('network') ||
|
||||
s.includes('could not resolve host') ||
|
||||
s.includes('dial tcp')
|
||||
) {
|
||||
return { type: 'network_error', message: 'Network error — check your connection.' }
|
||||
}
|
||||
// Not found
|
||||
if (
|
||||
s.includes('http 404') ||
|
||||
errors.some((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') ||
|
||||
// Why: GitHub uses "to an" for vowel-leading types ("to an Issue", "to
|
||||
// an Organization") and "to a" otherwise. The previous singular-only
|
||||
// check missed the "an" variants when gh emits only the stderr summary
|
||||
// without a structured GraphQL error array. See bug-scan finding 3.
|
||||
/could not resolve to an? /.test(s)
|
||||
) {
|
||||
const firstNotFound = errors.find((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND')
|
||||
return {
|
||||
type: 'not_found',
|
||||
message: 'Project or view not found.',
|
||||
details: firstNotFound
|
||||
? { path: firstNotFound.path, code: firstNotFound.extensions?.code }
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
// Validation
|
||||
if (s.includes('http 422') || s.includes('validation failed')) {
|
||||
return { type: 'validation_error', message: `Invalid request — ${stderr.trim()}` }
|
||||
}
|
||||
// GraphQL error with structured info
|
||||
if (errors.length > 0) {
|
||||
const first = errors[0]
|
||||
return {
|
||||
type: 'unknown',
|
||||
message: first.message ?? 'Unknown GraphQL error.',
|
||||
details: { path: first.path, code: first.extensions?.code }
|
||||
}
|
||||
}
|
||||
// Why: don't leak full stderr to the UI — it can include verbose request
|
||||
// dumps with header diagnostics. Truncate to the first non-empty line and
|
||||
// cap length so unexpected diagnostics stay readable but bounded.
|
||||
const firstLine =
|
||||
stderr
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.length > 0) ?? ''
|
||||
const safe = firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine
|
||||
return {
|
||||
type: 'unknown',
|
||||
message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.'
|
||||
}
|
||||
}
|
||||
|
||||
export function driftError(
|
||||
reason: string,
|
||||
details?: { path?: (string | number)[]; code?: string }
|
||||
): GitHubProjectViewError {
|
||||
return { type: 'schema_drift', message: `Could not read this project view: ${reason}.`, details }
|
||||
}
|
||||
|
||||
// Why: the rate-limit circuit breaker short-circuits before we spawn `gh`
|
||||
// when the cached snapshot says we're below the safety floor. Synthesize the
|
||||
// same `rate_limited` error shape as the post-hoc classifier so the UI path
|
||||
// is unchanged. We DO NOT fail open here when there's no cached snapshot —
|
||||
// rateLimitGuard already handles that case (returns `blocked:false`).
|
||||
export function rateLimitedError(blocked: {
|
||||
remaining: number
|
||||
limit: number
|
||||
resetAt: number
|
||||
}): GitHubProjectViewError {
|
||||
const resetIn = Math.max(0, blocked.resetAt - Math.floor(Date.now() / 1000))
|
||||
const mins = Math.ceil(resetIn / 60)
|
||||
return {
|
||||
type: 'rate_limited',
|
||||
message: `GitHub rate limit nearly exhausted (${blocked.remaining}/${blocked.limit} left). Resets in ~${mins}m.`
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Low-level gh api graphql invocation ───────────────────────────────
|
||||
|
||||
export type GraphqlVars = Record<string, string | number | boolean>
|
||||
@@ -265,12 +126,14 @@ export type GraphqlVars = Record<string, string | number | boolean>
|
||||
export async function runGraphql<T>(
|
||||
query: string,
|
||||
vars: GraphqlVars,
|
||||
cwd?: string
|
||||
exec?: ProjectGhExecOptions
|
||||
): Promise<
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; error: GitHubProjectViewError; raw: { stderr: string; stdout: string } }
|
||||
> {
|
||||
const guard = rateLimitGuard('graphql')
|
||||
// Why: GHES traffic runs against its own quota — only github.com requests
|
||||
// consult/debit the shared snapshot.
|
||||
const guard = repositoryRateLimitGuard({ host: exec?.host }, 'graphql')
|
||||
if (guard.blocked) {
|
||||
return { ok: false, error: rateLimitedError(guard), raw: { stderr: '', stdout: '' } }
|
||||
}
|
||||
@@ -286,11 +149,12 @@ export async function runGraphql<T>(
|
||||
}
|
||||
}
|
||||
await acquire()
|
||||
noteRateLimitSpend('graphql')
|
||||
noteRepositoryRateLimitSpend({ host: exec?.host }, 'graphql')
|
||||
try {
|
||||
const { stdout, stderr } = await ghExecFileAsync(args, {
|
||||
encoding: 'utf-8',
|
||||
...(cwd ? { cwd } : {})
|
||||
...(exec?.cwd ? { cwd: exec.cwd } : {}),
|
||||
...(exec?.host ? { host: exec.host } : {})
|
||||
})
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as { data?: T; errors?: GhGraphqlErrorShape[] }
|
||||
@@ -336,18 +200,21 @@ export async function runRest<T>(
|
||||
args: string[],
|
||||
cwd?: string,
|
||||
bucket: RateLimitBucketKind = 'core',
|
||||
options?: { expectEmpty?: boolean }
|
||||
options?: { expectEmpty?: boolean; host?: string }
|
||||
): Promise<{ ok: true; data: T } | { ok: false; error: GitHubProjectViewError }> {
|
||||
const guard = rateLimitGuard(bucket)
|
||||
// Why: GHES traffic runs against its own quota — only github.com requests
|
||||
// consult/debit the shared snapshot.
|
||||
const guard = repositoryRateLimitGuard({ host: options?.host }, bucket)
|
||||
if (guard.blocked) {
|
||||
return { ok: false, error: rateLimitedError(guard) }
|
||||
}
|
||||
await acquire()
|
||||
noteRateLimitSpend(bucket)
|
||||
noteRepositoryRateLimitSpend({ host: options?.host }, bucket)
|
||||
try {
|
||||
const { stdout, stderr } = await ghExecFileAsync(['api', ...args], {
|
||||
encoding: 'utf-8',
|
||||
...(cwd ? { cwd } : {})
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(options?.host ? { host: options.host } : {})
|
||||
})
|
||||
// Why: 204/empty-body endpoints (DELETE label, DELETE comment) return no
|
||||
// body. Treat empty stdout as success rather than misclassifying the
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { acquireMock, ghExecFileAsyncMock, releaseMock, runRestMock } = vi.hoisted(() => ({
|
||||
acquireMock: vi.fn(),
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
releaseMock: vi.fn(),
|
||||
runRestMock: vi.fn()
|
||||
}))
|
||||
const { acquireMock, ghExecFileAsyncMock, releaseMock, runGraphqlMock, runRestMock } = vi.hoisted(
|
||||
() => ({
|
||||
acquireMock: vi.fn(),
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
releaseMock: vi.fn(),
|
||||
runGraphqlMock: vi.fn(),
|
||||
runRestMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('./internals', () => ({
|
||||
acquire: acquireMock,
|
||||
@@ -17,9 +20,12 @@ vi.mock('./internals', () => ({
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
rateLimitGuard: () => ({ blocked: false }),
|
||||
noteRateLimitSpend: vi.fn(),
|
||||
repositoryRateLimitGuard: () => ({ blocked: false }),
|
||||
noteRepositoryRateLimitSpend: vi.fn(),
|
||||
projectGhExecOptions: (host?: string) => (host ? { host } : {}),
|
||||
classifyProjectError: (stderr: string) => ({ type: 'unknown', message: stderr }),
|
||||
rateLimitedError: () => ({ type: 'rate_limited', message: 'rate limited' }),
|
||||
runGraphql: vi.fn(),
|
||||
runGraphql: runGraphqlMock,
|
||||
runRest: runRestMock,
|
||||
validateSlugArgs: (owner: string, repo: string) =>
|
||||
owner && repo ? { ok: true } : { ok: false, error: { type: 'validation_error' } },
|
||||
@@ -29,7 +35,7 @@ vi.mock('./internals', () => ({
|
||||
: { ok: false, error: { type: 'validation_error', message: `${name} invalid` } }
|
||||
}))
|
||||
|
||||
import { updateIssueBySlug } from './mutations'
|
||||
import { getWorkItemDetailsBySlug, updateIssueBySlug } from './mutations'
|
||||
|
||||
describe('updateIssueBySlug', () => {
|
||||
beforeEach(() => {
|
||||
@@ -118,4 +124,65 @@ describe('updateIssueBySlug', () => {
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
})
|
||||
|
||||
it('threads the GHES host into gh exec and REST options', async () => {
|
||||
await expect(
|
||||
updateIssueBySlug({
|
||||
owner: 'acme',
|
||||
repo: 'widgets',
|
||||
number: 12,
|
||||
host: 'github.corp.example',
|
||||
updates: { state: 'closed', stateReason: 'completed', title: 'New title' }
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['issue', 'close', '12', '--repo', 'acme/widgets', '--reason', 'completed'],
|
||||
{ encoding: 'utf-8', host: 'github.corp.example' }
|
||||
)
|
||||
expect(runRestMock).toHaveBeenCalledWith(
|
||||
['-X', 'PATCH', 'repos/acme/widgets/issues/12', '--raw-field', 'title=New title'],
|
||||
undefined,
|
||||
'core',
|
||||
{ host: 'github.corp.example' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkItemDetailsBySlug', () => {
|
||||
beforeEach(() => {
|
||||
runGraphqlMock.mockReset()
|
||||
})
|
||||
|
||||
it('threads the GHES host into the GraphQL exec options', async () => {
|
||||
runGraphqlMock.mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
repository: {
|
||||
issue: {
|
||||
id: 'I_1',
|
||||
number: 7,
|
||||
title: 'Enterprise issue',
|
||||
url: 'https://github.corp.example/acme/widgets/issues/7',
|
||||
state: 'OPEN'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await getWorkItemDetailsBySlug({
|
||||
owner: 'acme',
|
||||
repo: 'widgets',
|
||||
number: 7,
|
||||
type: 'issue',
|
||||
host: 'github.corp.example'
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(runGraphqlMock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ owner: 'acme', repo: 'widgets', num: 7 },
|
||||
{ host: 'github.corp.example' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,16 +6,17 @@ import {
|
||||
release,
|
||||
extractExecError,
|
||||
ghExecFileAsync,
|
||||
rateLimitGuard,
|
||||
noteRateLimitSpend,
|
||||
classifyProjectError,
|
||||
rateLimitedError,
|
||||
repositoryRateLimitGuard,
|
||||
noteRepositoryRateLimitSpend,
|
||||
runGraphql,
|
||||
runRest,
|
||||
validateSlugArgs,
|
||||
assertPositiveInt,
|
||||
projectGhExecOptions,
|
||||
type GraphqlVars
|
||||
} from './internals'
|
||||
import { classifyProjectError, rateLimitedError } from './project-error-classification'
|
||||
import { githubHostExecOptions } from '../github-api-repository'
|
||||
import type { GitHubAssignableUser, GitHubWorkItemDetails, PRComment } from '../../../shared/types'
|
||||
import type {
|
||||
AddIssueCommentBySlugArgs,
|
||||
@@ -122,7 +123,7 @@ export async function updateProjectItemFieldValue(
|
||||
fieldId: args.fieldId,
|
||||
value: valVar.val
|
||||
}
|
||||
const res = await runGraphql<unknown>(query, vars)
|
||||
const res = await runGraphql<unknown>(query, vars, projectGhExecOptions(args.host))
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: res.error }
|
||||
}
|
||||
@@ -144,11 +145,15 @@ export async function clearProjectItemFieldValue(
|
||||
}) { projectV2Item { id } }
|
||||
}
|
||||
`
|
||||
const res = await runGraphql<unknown>(query, {
|
||||
projectId: args.projectId,
|
||||
itemId: args.itemId,
|
||||
fieldId: args.fieldId
|
||||
})
|
||||
const res = await runGraphql<unknown>(
|
||||
query,
|
||||
{
|
||||
projectId: args.projectId,
|
||||
itemId: args.itemId,
|
||||
fieldId: args.fieldId
|
||||
},
|
||||
projectGhExecOptions(args.host)
|
||||
)
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: res.error }
|
||||
}
|
||||
@@ -229,7 +234,7 @@ export async function updateIssueBySlug(
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
await ghExecFileAsync(stateArgs, { encoding: 'utf-8' })
|
||||
await ghExecFileAsync(stateArgs, { encoding: 'utf-8', ...githubHostExecOptions(args) })
|
||||
} catch (err) {
|
||||
const { stderr, stdout } = extractExecError(err)
|
||||
return { ok: false, error: classifyProjectError(stderr, stdout) }
|
||||
@@ -247,7 +252,7 @@ export async function updateIssueBySlug(
|
||||
if (body !== undefined) {
|
||||
patchArgs.push('--raw-field', `body=${body}`)
|
||||
}
|
||||
const r = await runRest<unknown>(patchArgs)
|
||||
const r = await runRest<unknown>(patchArgs, undefined, 'core', githubHostExecOptions(args))
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
@@ -263,7 +268,12 @@ export async function updateIssueBySlug(
|
||||
const addCount = addLabels?.length ?? 0
|
||||
if (removeCount > 1) {
|
||||
type RawLabelResp = { name?: string }[]
|
||||
const fetched = await runRest<RawLabelResp>(['-X', 'GET', `${base}/labels`])
|
||||
const fetched = await runRest<RawLabelResp>(
|
||||
['-X', 'GET', `${base}/labels`],
|
||||
undefined,
|
||||
'core',
|
||||
githubHostExecOptions(args)
|
||||
)
|
||||
if (!fetched.ok) {
|
||||
return { ok: false, error: fetched.error }
|
||||
}
|
||||
@@ -282,7 +292,8 @@ export async function updateIssueBySlug(
|
||||
// dedicated DELETE endpoint is the documented way to remove all
|
||||
// labels in a single call.
|
||||
const r = await runRest<unknown>(['-X', 'DELETE', `${base}/labels`], undefined, 'core', {
|
||||
expectEmpty: true
|
||||
expectEmpty: true,
|
||||
...githubHostExecOptions(args)
|
||||
})
|
||||
if (!r.ok && r.error.type !== 'not_found') {
|
||||
return { ok: false, error: r.error }
|
||||
@@ -292,7 +303,7 @@ export async function updateIssueBySlug(
|
||||
for (const name of currentNames) {
|
||||
putArgs.push('--raw-field', `labels[]=${name}`)
|
||||
}
|
||||
const r = await runRest<unknown>(putArgs)
|
||||
const r = await runRest<unknown>(putArgs, undefined, 'core', githubHostExecOptions(args))
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
@@ -303,7 +314,7 @@ export async function updateIssueBySlug(
|
||||
for (const l of addLabels ?? []) {
|
||||
restArgs.push('--raw-field', `labels[]=${l}`)
|
||||
}
|
||||
const r = await runRest<unknown>(restArgs)
|
||||
const r = await runRest<unknown>(restArgs, undefined, 'core', githubHostExecOptions(args))
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
@@ -313,7 +324,7 @@ export async function updateIssueBySlug(
|
||||
['-X', 'DELETE', `${base}/labels/${encodeURIComponent(removeLabels![0])}`],
|
||||
undefined,
|
||||
'core',
|
||||
{ expectEmpty: true }
|
||||
{ expectEmpty: true, ...githubHostExecOptions(args) }
|
||||
)
|
||||
if (!r.ok && r.error.type !== 'not_found') {
|
||||
return { ok: false, error: r.error }
|
||||
@@ -328,7 +339,7 @@ export async function updateIssueBySlug(
|
||||
for (const u of addAssignees) {
|
||||
restArgs.push('--raw-field', `assignees[]=${u}`)
|
||||
}
|
||||
const r = await runRest<unknown>(restArgs)
|
||||
const r = await runRest<unknown>(restArgs, undefined, 'core', githubHostExecOptions(args))
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
@@ -338,7 +349,7 @@ export async function updateIssueBySlug(
|
||||
for (const u of removeAssignees) {
|
||||
restArgs.push('--raw-field', `assignees[]=${u}`)
|
||||
}
|
||||
const r = await runRest<unknown>(restArgs)
|
||||
const r = await runRest<unknown>(restArgs, undefined, 'core', githubHostExecOptions(args))
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
@@ -384,7 +395,7 @@ export async function updatePullRequestBySlug(
|
||||
// No fields to update — nothing to do.
|
||||
return { ok: true }
|
||||
}
|
||||
const r = await runRest<unknown>(patchArgs)
|
||||
const r = await runRest<unknown>(patchArgs, undefined, 'core', githubHostExecOptions(args))
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
@@ -425,13 +436,18 @@ export async function addIssueCommentBySlug(
|
||||
if (typeof args.body !== 'string' || !args.body.trim()) {
|
||||
return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } }
|
||||
}
|
||||
const r = await runRest<RawIssueCommentResponse>([
|
||||
'-X',
|
||||
'POST',
|
||||
`repos/${args.owner}/${args.repo}/issues/${args.number}/comments`,
|
||||
'--raw-field',
|
||||
`body=${args.body}`
|
||||
])
|
||||
const r = await runRest<RawIssueCommentResponse>(
|
||||
[
|
||||
'-X',
|
||||
'POST',
|
||||
`repos/${args.owner}/${args.repo}/issues/${args.number}/comments`,
|
||||
'--raw-field',
|
||||
`body=${args.body}`
|
||||
],
|
||||
undefined,
|
||||
'core',
|
||||
githubHostExecOptions(args)
|
||||
)
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
@@ -452,13 +468,18 @@ export async function updateIssueCommentBySlug(
|
||||
if (typeof args.body !== 'string' || !args.body.trim()) {
|
||||
return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } }
|
||||
}
|
||||
const r = await runRest<unknown>([
|
||||
'-X',
|
||||
'PATCH',
|
||||
`repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`,
|
||||
'--raw-field',
|
||||
`body=${args.body}`
|
||||
])
|
||||
const r = await runRest<unknown>(
|
||||
[
|
||||
'-X',
|
||||
'PATCH',
|
||||
`repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`,
|
||||
'--raw-field',
|
||||
`body=${args.body}`
|
||||
],
|
||||
undefined,
|
||||
'core',
|
||||
githubHostExecOptions(args)
|
||||
)
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
@@ -480,7 +501,7 @@ export async function deleteIssueCommentBySlug(
|
||||
['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`],
|
||||
undefined,
|
||||
'core',
|
||||
{ expectEmpty: true }
|
||||
{ expectEmpty: true, ...githubHostExecOptions(args) }
|
||||
)
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
@@ -497,18 +518,18 @@ export async function listLabelsBySlug(
|
||||
if (!v.ok) {
|
||||
return v
|
||||
}
|
||||
const guard = rateLimitGuard('core')
|
||||
const guard = repositoryRateLimitGuard(args, 'core')
|
||||
if (guard.blocked) {
|
||||
return { ok: false, error: rateLimitedError(guard) }
|
||||
}
|
||||
await acquire()
|
||||
// Why: `--paginate` may fan out to multiple pages; we can only reasonably
|
||||
// estimate a 1-call spend up front. The next probe will reconcile.
|
||||
noteRateLimitSpend('core')
|
||||
noteRepositoryRateLimitSpend(args, 'core')
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'],
|
||||
{ encoding: 'utf-8' }
|
||||
{ encoding: 'utf-8', ...githubHostExecOptions(args) }
|
||||
)
|
||||
return {
|
||||
ok: true,
|
||||
@@ -535,12 +556,12 @@ export async function listAssignableUsersBySlug(
|
||||
// Seed logins merge after the fetch so callers can include currently-visible
|
||||
// assignees even if the repo participant search is sparse.
|
||||
const result: GitHubAssignableUser[] = []
|
||||
const guard = rateLimitGuard('core')
|
||||
const guard = repositoryRateLimitGuard(args, 'core')
|
||||
if (guard.blocked) {
|
||||
return { ok: false, error: rateLimitedError(guard) }
|
||||
}
|
||||
await acquire()
|
||||
noteRateLimitSpend('core')
|
||||
noteRepositoryRateLimitSpend(args, 'core')
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
@@ -550,7 +571,7 @@ export async function listAssignableUsersBySlug(
|
||||
'--jq',
|
||||
'.[] | {login: .login, name: null, avatarUrl: .avatar_url}'
|
||||
],
|
||||
{ encoding: 'utf-8' }
|
||||
{ encoding: 'utf-8', ...githubHostExecOptions(args) }
|
||||
)
|
||||
for (const line of stdout
|
||||
.trim()
|
||||
@@ -614,7 +635,7 @@ export async function listIssueTypesBySlug(
|
||||
} | null)[]
|
||||
} | null
|
||||
} | null
|
||||
}>(query, { owner: args.owner, repo: args.repo })
|
||||
}>(query, { owner: args.owner, repo: args.repo }, githubHostExecOptions(args))
|
||||
if (!res.ok) {
|
||||
// Why: repos without issue types respond with a GraphQL error claiming the
|
||||
// `issueTypes` field is unknown. Map that to an empty list so the UI shows
|
||||
@@ -659,7 +680,8 @@ export async function updateIssueTypeBySlug(
|
||||
`query($owner:String!, $repo:String!, $num:Int!) {
|
||||
repository(owner:$owner, name:$repo) { issue(number:$num) { id } }
|
||||
}`,
|
||||
{ owner: args.owner, repo: args.repo, num: args.number }
|
||||
{ owner: args.owner, repo: args.repo, num: args.number },
|
||||
githubHostExecOptions(args)
|
||||
)
|
||||
if (!lookup.ok) {
|
||||
return { ok: false, error: lookup.error }
|
||||
@@ -689,7 +711,7 @@ export async function updateIssueTypeBySlug(
|
||||
const vars: GraphqlVars = args.issueTypeId
|
||||
? { issueId, issueTypeId: args.issueTypeId }
|
||||
: { issueId }
|
||||
const res = await runGraphql<unknown>(query, vars)
|
||||
const res = await runGraphql<unknown>(query, vars, githubHostExecOptions(args))
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: res.error }
|
||||
}
|
||||
@@ -810,7 +832,7 @@ export async function getWorkItemDetailsBySlug(
|
||||
})
|
||||
| null
|
||||
} | null
|
||||
}>(query, { owner: args.owner, repo: args.repo, num: args.number })
|
||||
}>(query, { owner: args.owner, repo: args.repo, num: args.number }, githubHostExecOptions(args))
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: res.error }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Why: turns raw gh stderr/stdout into the typed GitHubProjectViewError the
|
||||
// renderer can act on (auth vs scope vs rate limit vs drift), shared by the
|
||||
// project-view read and mutation paths.
|
||||
import type { GitHubProjectViewError } from '../../../shared/github-project-types'
|
||||
|
||||
export type GhGraphqlErrorShape = {
|
||||
type?: string
|
||||
message?: string
|
||||
path?: (string | number)[]
|
||||
extensions?: { code?: string }
|
||||
}
|
||||
|
||||
export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlErrorShape[] {
|
||||
// `gh api graphql` prints the response JSON to stdout even on GraphQL
|
||||
// errors, and the stderr carries a summary. Try stdout first; if parsing
|
||||
// fails, fall back to stderr.
|
||||
const sources = [stdout, stderr]
|
||||
for (const src of sources) {
|
||||
if (!src) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(src) as { errors?: GhGraphqlErrorShape[] }
|
||||
if (parsed.errors && parsed.errors.length > 0) {
|
||||
return parsed.errors
|
||||
}
|
||||
} catch {
|
||||
// not JSON — continue
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function errorsIndicateParentField(errors: GhGraphqlErrorShape[], stderr: string): boolean {
|
||||
const lower = stderr.toLowerCase()
|
||||
// Preview-header shape: gh returns a 4xx with "preview" in the message.
|
||||
if (lower.includes('preview') && lower.includes('parent')) {
|
||||
return true
|
||||
}
|
||||
return errors.some((e) => {
|
||||
const type = (e.type ?? '').toUpperCase()
|
||||
if (type === 'FIELD_NOT_FOUND' || type === 'UNDEFINED_FIELD' || type === 'FIELD_ERRORS') {
|
||||
const tail = e.path?.at(-1)
|
||||
if (tail === 'parent') {
|
||||
return true
|
||||
}
|
||||
// FIELD_ERRORS often omits `path`; match on message for the parent field.
|
||||
if ((e.message ?? '').toLowerCase().includes('parent')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
export function classifyProjectError(stderr: string, stdout: string): GitHubProjectViewError {
|
||||
const errors = extractGraphqlErrors(stderr, stdout)
|
||||
const s = stderr.toLowerCase()
|
||||
|
||||
// Auth
|
||||
if (
|
||||
s.includes('authentication required') ||
|
||||
s.includes('not logged in') ||
|
||||
s.includes('gh auth login')
|
||||
) {
|
||||
return {
|
||||
type: 'auth_required',
|
||||
message: 'Sign in to GitHub to load project tasks. Run `gh auth login`.'
|
||||
}
|
||||
}
|
||||
// Scope
|
||||
if (
|
||||
s.includes('missing required scope') ||
|
||||
s.includes('your token has not been granted') ||
|
||||
(s.includes('resource not accessible') && (s.includes('project') || s.includes('scope')))
|
||||
) {
|
||||
return {
|
||||
type: 'scope_missing',
|
||||
message:
|
||||
'GitHub project access needs additional scopes. Run `gh auth refresh -s project -s read:org -s repo`.'
|
||||
}
|
||||
}
|
||||
// Rate limit
|
||||
if (s.includes('rate limit') || s.includes('api rate limit exceeded')) {
|
||||
return { type: 'rate_limited', message: 'GitHub rate limit hit. Try again in a few minutes.' }
|
||||
}
|
||||
// Network — checked BEFORE not_found because DNS failures surface as
|
||||
// "could not resolve host", which would otherwise be partially matched by
|
||||
// the not_found branch's "could not resolve" check. Substring matching here
|
||||
// is a one-way trapdoor: a real GraphQL "Could not resolve to a User…"
|
||||
// error always contains "to a", so we tighten the not_found check below to
|
||||
// require that token.
|
||||
if (
|
||||
s.includes('timeout') ||
|
||||
s.includes('no such host') ||
|
||||
s.includes('network') ||
|
||||
s.includes('could not resolve host') ||
|
||||
s.includes('dial tcp')
|
||||
) {
|
||||
return { type: 'network_error', message: 'Network error — check your connection.' }
|
||||
}
|
||||
// Not found
|
||||
if (
|
||||
s.includes('http 404') ||
|
||||
errors.some((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') ||
|
||||
// Why: GitHub uses "to an" for vowel-leading types ("to an Issue", "to
|
||||
// an Organization") and "to a" otherwise. The previous singular-only
|
||||
// check missed the "an" variants when gh emits only the stderr summary
|
||||
// without a structured GraphQL error array. See bug-scan finding 3.
|
||||
/could not resolve to an? /.test(s)
|
||||
) {
|
||||
const firstNotFound = errors.find((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND')
|
||||
return {
|
||||
type: 'not_found',
|
||||
message: 'Project or view not found.',
|
||||
details: firstNotFound
|
||||
? { path: firstNotFound.path, code: firstNotFound.extensions?.code }
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
// Validation
|
||||
if (s.includes('http 422') || s.includes('validation failed')) {
|
||||
return { type: 'validation_error', message: `Invalid request — ${stderr.trim()}` }
|
||||
}
|
||||
// GraphQL error with structured info
|
||||
if (errors.length > 0) {
|
||||
const first = errors[0]
|
||||
return {
|
||||
type: 'unknown',
|
||||
message: first.message ?? 'Unknown GraphQL error.',
|
||||
details: { path: first.path, code: first.extensions?.code }
|
||||
}
|
||||
}
|
||||
// Why: don't leak full stderr to the UI — it can include verbose request
|
||||
// dumps with header diagnostics. Truncate to the first non-empty line and
|
||||
// cap length so unexpected diagnostics stay readable but bounded.
|
||||
const firstLine =
|
||||
stderr
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.length > 0) ?? ''
|
||||
const safe = firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine
|
||||
return {
|
||||
type: 'unknown',
|
||||
message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.'
|
||||
}
|
||||
}
|
||||
|
||||
export function driftError(
|
||||
reason: string,
|
||||
details?: { path?: (string | number)[]; code?: string }
|
||||
): GitHubProjectViewError {
|
||||
return { type: 'schema_drift', message: `Could not read this project view: ${reason}.`, details }
|
||||
}
|
||||
|
||||
// Why: the rate-limit circuit breaker short-circuits before we spawn `gh`
|
||||
// when the cached snapshot says we're below the safety floor. Synthesize the
|
||||
// same `rate_limited` error shape as the post-hoc classifier so the UI path
|
||||
// is unchanged. We DO NOT fail open here when there's no cached snapshot —
|
||||
// rateLimitGuard already handles that case (returns `blocked:false`).
|
||||
export function rateLimitedError(blocked: {
|
||||
remaining: number
|
||||
limit: number
|
||||
resetAt: number
|
||||
}): GitHubProjectViewError {
|
||||
const resetIn = Math.max(0, blocked.resetAt - Math.floor(Date.now() / 1000))
|
||||
const mins = Math.ceil(resetIn / 60)
|
||||
return {
|
||||
type: 'rate_limited',
|
||||
message: `GitHub rate limit nearly exhausted (${blocked.remaining}/${blocked.limit} left). Resets in ~${mins}m.`
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
noteRepositoryRateLimitSpend,
|
||||
_resetRateLimitCache
|
||||
} from './rate-limit'
|
||||
import {
|
||||
clearGhRateLimitBlock,
|
||||
getGhRateLimitBlockedUntilMs,
|
||||
notifyGhPrimaryRateLimit,
|
||||
type GhRateLimitBucket
|
||||
} from '../git/gh-rate-limit-breaker'
|
||||
|
||||
const PAYLOAD = JSON.stringify({
|
||||
resources: {
|
||||
@@ -191,6 +197,39 @@ describe('shared-quota host scoping', () => {
|
||||
expect(spendsSharedGitHubComQuota(undefined, { wslDistro: 'Ubuntu' })).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a WSL UNC cwd as non-shared even without an explicit wslDistro', () => {
|
||||
// Why: the runner derives the WSL distro from a \\wsl.localhost\... cwd, so
|
||||
// gh runs inside WSL and spends that distro's quota — not the native one.
|
||||
expect(
|
||||
spendsSharedGitHubComQuota(
|
||||
{ host: 'github.com' },
|
||||
{ cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' }
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
spendsSharedGitHubComQuota(
|
||||
{ host: 'github.com' },
|
||||
{ cwd: '\\\\wsl$\\Ubuntu\\home\\me\\repo' }
|
||||
)
|
||||
).toBe(false)
|
||||
// Regular Windows/POSIX cwds keep sharing the native github.com budget.
|
||||
expect(spendsSharedGitHubComQuota({ host: 'github.com' }, { cwd: 'C:\\repos\\a' })).toBe(true)
|
||||
expect(spendsSharedGitHubComQuota({ host: 'github.com' }, { cwd: '/Users/me/repo' })).toBe(true)
|
||||
})
|
||||
|
||||
it('bypasses the guard and spend for a WSL UNC cwd', async () => {
|
||||
await cacheExhaustedSnapshot(61)
|
||||
|
||||
const cwd = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'
|
||||
expect(repositoryRateLimitGuard({ host: 'github.com' }, 'core', { cwd })).toEqual({
|
||||
blocked: false
|
||||
})
|
||||
|
||||
noteRepositoryRateLimitSpend({ host: 'github.com' }, 'core', 1, { cwd })
|
||||
const guard = rateLimitGuard('core')
|
||||
expect(guard.blocked && guard.remaining).toBe(3)
|
||||
})
|
||||
|
||||
it('applies the shared guard for github.com but bypasses non-shared scopes', async () => {
|
||||
await cacheExhaustedSnapshot(61)
|
||||
|
||||
@@ -227,3 +266,101 @@ describe('shared-quota host scoping', () => {
|
||||
expect(guard.blocked && guard.remaining).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// Why: the breaker's reset probe must query the tripping scope's runtime/host
|
||||
// (WSL distro, GHES host), record into that scope only, and never touch the
|
||||
// shared native-github.com snapshot.
|
||||
describe('scope-aware breaker reset probe', () => {
|
||||
const WSL_SCOPE = 'wsl:ubuntu:github.com'
|
||||
const GHES_SCOPE = 'native:github.acme-corp.com'
|
||||
const ALL_BUCKETS: GhRateLimitBucket[] = ['core', 'search', 'graphql']
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
_resetRateLimitCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clear per-scope breaker state without unregistering the module-load
|
||||
// probe (which _resetGhRateLimitBreaker would drop for later tests).
|
||||
for (const scope of [WSL_SCOPE, GHES_SCOPE]) {
|
||||
for (const bucket of ALL_BUCKETS) {
|
||||
clearGhRateLimitBlock(bucket, scope)
|
||||
}
|
||||
}
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function flushProbe(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
it('refines a WSL-scope trip via a probe matching that scope', async () => {
|
||||
// resetAt 601s > the 5-minute fallback so the refinement is observable.
|
||||
ghExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
resources: {
|
||||
core: { limit: 5000, remaining: 0, reset: 601 },
|
||||
search: { limit: 30, remaining: 20, reset: 601 },
|
||||
graphql: { limit: 5000, remaining: 100, reset: 601 }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
notifyGhPrimaryRateLimit('core', WSL_SCOPE)
|
||||
// Single-flight: a concurrent second trip must not spawn a second probe.
|
||||
notifyGhPrimaryRateLimit('core', WSL_SCOPE)
|
||||
await flushProbe()
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'rate_limit'], {
|
||||
encoding: 'utf-8',
|
||||
host: 'github.com',
|
||||
wslDistro: 'ubuntu'
|
||||
})
|
||||
// The exhausted core bucket is refined to the real reset for THAT scope.
|
||||
expect(getGhRateLimitBlockedUntilMs('core', 1_000, WSL_SCOPE)).toBe(601_000)
|
||||
// Non-exhausted buckets in the same scope stay clear.
|
||||
expect(getGhRateLimitBlockedUntilMs('search', 1_000, WSL_SCOPE)).toBeNull()
|
||||
// The shared native-github.com snapshot was not written: the guard has no
|
||||
// cached data and fails open.
|
||||
expect(rateLimitGuard('core')).toEqual({ blocked: false })
|
||||
expect(getGhRateLimitBlockedUntilMs('core', 1_000)).toBeNull()
|
||||
})
|
||||
|
||||
it('fails open on a GHES probe failure and negative-caches the scope', async () => {
|
||||
ghExecFileAsyncMock.mockRejectedValue(new Error('HTTP 404: Rate limiting is not enabled.'))
|
||||
|
||||
notifyGhPrimaryRateLimit('core', GHES_SCOPE)
|
||||
await flushProbe()
|
||||
// A second trip inside the failure TTL must not spawn another probe.
|
||||
notifyGhPrimaryRateLimit('core', GHES_SCOPE)
|
||||
await flushProbe()
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
// Fail open: the blunt fallback block stands (5 minutes from trip time).
|
||||
expect(getGhRateLimitBlockedUntilMs('core', 1_000, GHES_SCOPE)).toBe(301_000)
|
||||
|
||||
// Past the TTL the scope is probed again (and can recover on success).
|
||||
vi.setSystemTime(1_000 + 30_000)
|
||||
ghExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
resources: { core: { limit: 5000, remaining: 4999, reset: 700 } }
|
||||
})
|
||||
})
|
||||
notifyGhPrimaryRateLimit('core', GHES_SCOPE)
|
||||
await flushProbe()
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenLastCalledWith(['api', 'rate_limit'], {
|
||||
encoding: 'utf-8',
|
||||
host: 'github.acme-corp.com'
|
||||
})
|
||||
// Budget is actually available in that scope — the block is cleared.
|
||||
expect(getGhRateLimitBlockedUntilMs('core', 31_000, GHES_SCOPE)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,11 +19,14 @@ import type {
|
||||
GitHubRateLimitSnapshot
|
||||
} from '../../shared/types'
|
||||
import { isDefaultGitHubHost } from '../../shared/github-repository-identity-key'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import { acquire, release } from './gh-utils'
|
||||
import { ghExecFileAsync } from '../git/runner'
|
||||
import {
|
||||
clearGhRateLimitBlock,
|
||||
getGhRateLimitBlockedUntilMs,
|
||||
ghRateLimitScopeKey,
|
||||
parseGhRateLimitScopeKey,
|
||||
recordGhPrimaryRateLimit,
|
||||
registerGhRateLimitResetProbe,
|
||||
type GhRateLimitBucket
|
||||
@@ -73,6 +76,8 @@ function parseBucket(
|
||||
export function _resetRateLimitCache(): void {
|
||||
cached = null
|
||||
probeFailure = null
|
||||
scopeProbeFailureAtMs.clear()
|
||||
scopeRefinementInFlight.clear()
|
||||
}
|
||||
|
||||
// Why: hard-stop thresholds for the circuit breaker. We refuse to issue a new
|
||||
@@ -167,15 +172,21 @@ export function noteRateLimitSpend(bucket: RateLimitBucketKind, cost = 1): void
|
||||
// breaker still protects those hosts after a real 403).
|
||||
export function spendsSharedGitHubComQuota(
|
||||
repository: { host?: string } | null | undefined,
|
||||
executionOptions?: { wslDistro?: string }
|
||||
executionOptions?: { wslDistro?: string; cwd?: string }
|
||||
): boolean {
|
||||
return isDefaultGitHubHost(repository?.host) && !executionOptions?.wslDistro
|
||||
if (!isDefaultGitHubHost(repository?.host) || executionOptions?.wslDistro) {
|
||||
return false
|
||||
}
|
||||
// Why: a \\wsl.localhost\... (or \\wsl$\...) cwd makes the runner spawn gh
|
||||
// inside WSL, so it spends that distro's quota. The UNC shape is
|
||||
// platform-unambiguous, so no win32 gate is needed.
|
||||
return !(executionOptions?.cwd && isWslUncPath(executionOptions.cwd))
|
||||
}
|
||||
|
||||
export function repositoryRateLimitGuard(
|
||||
repository: { host?: string } | null | undefined,
|
||||
bucket: RateLimitBucketKind,
|
||||
executionOptions?: { wslDistro?: string }
|
||||
executionOptions?: { wslDistro?: string; cwd?: string }
|
||||
): ReturnType<typeof rateLimitGuard> {
|
||||
return spendsSharedGitHubComQuota(repository, executionOptions)
|
||||
? rateLimitGuard(bucket)
|
||||
@@ -186,7 +197,7 @@ export function noteRepositoryRateLimitSpend(
|
||||
repository: { host?: string } | null | undefined,
|
||||
bucket: RateLimitBucketKind,
|
||||
cost = 1,
|
||||
executionOptions?: { wslDistro?: string }
|
||||
executionOptions?: { wslDistro?: string; cwd?: string }
|
||||
): void {
|
||||
if (spendsSharedGitHubComQuota(repository, executionOptions)) {
|
||||
noteRateLimitSpend(bucket, cost)
|
||||
@@ -224,7 +235,68 @@ function refineBreakerFromSnapshot(): void {
|
||||
})()
|
||||
}
|
||||
|
||||
registerGhRateLimitResetProbe(() => refineBreakerFromSnapshot())
|
||||
const DEFAULT_BREAKER_SCOPE = ghRateLimitScopeKey('native', 'github.com')
|
||||
// Why: non-default scopes (GHES hosts, WSL runtimes) have their own quota and
|
||||
// their own probe lifecycle — single-flight and failure negative-cache are
|
||||
// keyed per scope so a GHES 403 storm can't starve a WSL refinement (or vice
|
||||
// versa).
|
||||
const scopeRefinementInFlight = new Map<string, Promise<void>>()
|
||||
const scopeProbeFailureAtMs = new Map<string, number>()
|
||||
|
||||
function refineBreakerForScope(scope: string): void {
|
||||
// Default scope keeps the existing shared-snapshot refinement path.
|
||||
if (scope === DEFAULT_BREAKER_SCOPE) {
|
||||
refineBreakerFromSnapshot()
|
||||
return
|
||||
}
|
||||
const parts = parseGhRateLimitScopeKey(scope)
|
||||
if (!parts || scopeRefinementInFlight.has(scope)) {
|
||||
return
|
||||
}
|
||||
// Why: GHES with rate limiting disabled 404s every probe. Fail open (the
|
||||
// fallback block stands) and don't re-probe in a tight loop while the
|
||||
// breaker keeps tripping.
|
||||
const failedAt = scopeProbeFailureAtMs.get(scope)
|
||||
if (failedAt !== undefined && Date.now() - failedAt < RATE_LIMIT_CACHE_TTL_MS) {
|
||||
return
|
||||
}
|
||||
const probe = (async () => {
|
||||
try {
|
||||
await acquire()
|
||||
try {
|
||||
// Run the probe on the tripping scope's runtime/host so it describes
|
||||
// that account's buckets, not the native github.com ones.
|
||||
const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], {
|
||||
encoding: 'utf-8',
|
||||
host: parts.host,
|
||||
...(parts.runtime === 'wsl' ? { wslDistro: parts.wslDistro } : {})
|
||||
})
|
||||
const parsed = JSON.parse(stdout) as GhRateLimitPayload
|
||||
scopeProbeFailureAtMs.delete(scope)
|
||||
// Why: mirrors the default-scope refinement, but records into the
|
||||
// per-scope breaker only — the shared snapshot must keep describing
|
||||
// native github.com exclusively.
|
||||
for (const bucket of ['core', 'search', 'graphql'] as GhRateLimitBucket[]) {
|
||||
const b = parseBucket(parsed.resources?.[bucket])
|
||||
if (b.limit > 0 && b.remaining <= 0) {
|
||||
recordGhPrimaryRateLimit(bucket, b.resetAt * 1000, scope)
|
||||
} else if (b.limit > 0) {
|
||||
clearGhRateLimitBlock(bucket, scope)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
} catch {
|
||||
scopeProbeFailureAtMs.set(scope, Date.now())
|
||||
} finally {
|
||||
scopeRefinementInFlight.delete(scope)
|
||||
}
|
||||
})()
|
||||
scopeRefinementInFlight.set(scope, probe)
|
||||
}
|
||||
|
||||
registerGhRateLimitResetProbe((_bucket, scope) => refineBreakerForScope(scope))
|
||||
|
||||
// Why: a 90-repo fan-out that primes the guard concurrently must resolve to
|
||||
// one `gh api rate_limit` spawn, not one per repo — the TTL cache alone can't
|
||||
|
||||
@@ -93,6 +93,7 @@ import type {
|
||||
ClearProjectItemFieldArgs,
|
||||
DeleteIssueCommentBySlugArgs,
|
||||
GetProjectViewTableArgs,
|
||||
ListAccessibleProjectsArgs,
|
||||
ListAssignableUsersBySlugArgs,
|
||||
ListIssueTypesBySlugArgs,
|
||||
ListLabelsBySlugArgs,
|
||||
@@ -1168,7 +1169,9 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
// Handlers never throw across IPC — every failure mode resolves through the
|
||||
// GitHubProjectViewError envelope.
|
||||
|
||||
ipcMain.handle('gh:listAccessibleProjects', () => listAccessibleProjects())
|
||||
ipcMain.handle('gh:listAccessibleProjects', (_event, args?: ListAccessibleProjectsArgs) =>
|
||||
listAccessibleProjects(args)
|
||||
)
|
||||
|
||||
ipcMain.handle('gh:resolveProjectRef', (_event, args: ResolveProjectRefArgs) =>
|
||||
resolveProjectRef(args)
|
||||
|
||||
@@ -179,7 +179,8 @@ describe('detectRepoIcon', () => {
|
||||
source: 'github',
|
||||
label: 'stablyai/orca'
|
||||
},
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
// Why: fork parents resolve host-qualified so avatars/links stay on the fork's server.
|
||||
upstream: { owner: 'stablyai', repo: 'orca', host: 'github.com' }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -7482,7 +7482,7 @@ describe('OrcaRuntimeService', () => {
|
||||
kind: 'pr-link',
|
||||
link: {
|
||||
url: 'https://github.com/acme/orca/pull/42',
|
||||
slug: { owner: 'acme', repo: 'orca' },
|
||||
slug: { owner: 'acme', repo: 'orca', host: 'github.com' },
|
||||
number: 42
|
||||
}
|
||||
},
|
||||
@@ -7490,7 +7490,7 @@ describe('OrcaRuntimeService', () => {
|
||||
kind: 'pr-link',
|
||||
link: {
|
||||
url: 'https://github.com/acme/orca/pull/43',
|
||||
slug: { owner: 'acme', repo: 'orca' },
|
||||
slug: { owner: 'acme', repo: 'orca', host: 'github.com' },
|
||||
number: 43
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,6 +600,7 @@ import {
|
||||
import type {
|
||||
ClearProjectItemFieldArgs,
|
||||
GetProjectViewTableArgs,
|
||||
ListAccessibleProjectsArgs,
|
||||
ListAssignableUsersBySlugArgs,
|
||||
ListIssueTypesBySlugArgs,
|
||||
ListLabelsBySlugArgs,
|
||||
@@ -14397,8 +14398,10 @@ export class OrcaRuntimeService {
|
||||
)
|
||||
}
|
||||
|
||||
async listGitHubProjects(): Promise<Awaited<ReturnType<typeof listAccessibleProjects>>> {
|
||||
return listAccessibleProjects()
|
||||
async listGitHubProjects(
|
||||
args?: ListAccessibleProjectsArgs
|
||||
): Promise<Awaited<ReturnType<typeof listAccessibleProjects>>> {
|
||||
return listAccessibleProjects(args)
|
||||
}
|
||||
|
||||
async listGitHubLabelsBySlug(
|
||||
|
||||
@@ -207,6 +207,9 @@ const ProjectOwnerType = z.enum(['organization', 'user'])
|
||||
|
||||
const ProjectViewTable = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
// Why: Enterprise host identity must survive RPC parsing; Zod strips
|
||||
// undeclared fields before the runtime can host-qualify gh requests.
|
||||
host: OptionalString,
|
||||
ownerType: ProjectOwnerType,
|
||||
projectNumber: z.number().int().positive(),
|
||||
viewId: OptionalString,
|
||||
@@ -221,17 +224,26 @@ const ProjectWorkItemDetailsBySlug = SlugRepo.extend({
|
||||
})
|
||||
|
||||
const ProjectRef = z.object({
|
||||
input: requiredString('Missing project reference')
|
||||
input: requiredString('Missing project reference'),
|
||||
// Why: Enterprise host identity must survive RPC parsing; Zod strips
|
||||
// undeclared fields before the runtime can host-qualify gh requests.
|
||||
host: OptionalString
|
||||
})
|
||||
|
||||
const ProjectViews = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
// Why: Enterprise host identity must survive RPC parsing; Zod strips
|
||||
// undeclared fields before the runtime can host-qualify gh requests.
|
||||
host: OptionalString,
|
||||
ownerType: ProjectOwnerType,
|
||||
projectNumber: z.number().int().positive()
|
||||
})
|
||||
|
||||
const ProjectItemField = z.object({
|
||||
projectId: requiredString('Missing project ID'),
|
||||
// Why: Enterprise host identity must survive RPC parsing; Zod strips
|
||||
// undeclared fields before the runtime can host-qualify gh requests.
|
||||
host: OptionalString,
|
||||
itemId: requiredString('Missing item ID'),
|
||||
fieldId: requiredString('Missing field ID'),
|
||||
value: z.any()
|
||||
@@ -239,6 +251,9 @@ const ProjectItemField = z.object({
|
||||
|
||||
const ClearProjectItemField = z.object({
|
||||
projectId: requiredString('Missing project ID'),
|
||||
// Why: Enterprise host identity must survive RPC parsing; Zod strips
|
||||
// undeclared fields before the runtime can host-qualify gh requests.
|
||||
host: OptionalString,
|
||||
itemId: requiredString('Missing item ID'),
|
||||
fieldId: requiredString('Missing field ID')
|
||||
})
|
||||
@@ -246,6 +261,9 @@ const ClearProjectItemField = z.object({
|
||||
const SlugIssueUpdate = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
// Why: Enterprise host identity must survive RPC parsing; Zod strips
|
||||
// undeclared fields before the runtime can host-qualify gh requests.
|
||||
host: OptionalString,
|
||||
number: z.number().int().positive(),
|
||||
updates: IssueUpdate
|
||||
})
|
||||
@@ -253,6 +271,9 @@ const SlugIssueUpdate = z.object({
|
||||
const SlugPullRequestUpdate = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
// Why: Enterprise host identity must survive RPC parsing; Zod strips
|
||||
// undeclared fields before the runtime can host-qualify gh requests.
|
||||
host: OptionalString,
|
||||
number: z.number().int().positive(),
|
||||
updates: z.object({
|
||||
state: z.enum(['open', 'closed']).optional(),
|
||||
@@ -264,6 +285,9 @@ const SlugPullRequestUpdate = z.object({
|
||||
const SlugIssueTypeUpdate = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
// Why: Enterprise host identity must survive RPC parsing; Zod strips
|
||||
// undeclared fields before the runtime can host-qualify gh requests.
|
||||
host: OptionalString,
|
||||
number: z.number().int().positive(),
|
||||
issueTypeId: z.string().nullable()
|
||||
})
|
||||
@@ -271,6 +295,9 @@ const SlugIssueTypeUpdate = z.object({
|
||||
const SlugIssueComment = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
// Why: Enterprise host identity must survive RPC parsing; Zod strips
|
||||
// undeclared fields before the runtime can host-qualify gh requests.
|
||||
host: OptionalString,
|
||||
number: z.number().int().positive(),
|
||||
body: requiredString('Comment body required')
|
||||
})
|
||||
@@ -278,6 +305,9 @@ const SlugIssueComment = z.object({
|
||||
const SlugIssueCommentEdit = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
// Why: Enterprise host identity must survive RPC parsing; Zod strips
|
||||
// undeclared fields before the runtime can host-qualify gh requests.
|
||||
host: OptionalString,
|
||||
commentId: z.number().int().positive(),
|
||||
body: requiredString('Comment body required')
|
||||
})
|
||||
@@ -285,6 +315,9 @@ const SlugIssueCommentEdit = z.object({
|
||||
const SlugIssueCommentDelete = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
// Why: Enterprise host identity must survive RPC parsing; Zod strips
|
||||
// undeclared fields before the runtime can host-qualify gh requests.
|
||||
host: OptionalString,
|
||||
commentId: z.number().int().positive()
|
||||
})
|
||||
|
||||
@@ -554,8 +587,8 @@ export const GITHUB_METHODS: RpcMethod[] = [
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.listAccessible',
|
||||
params: z.object({}),
|
||||
handler: async (_params, { runtime }) => runtime.listGitHubProjects()
|
||||
params: z.object({ host: OptionalString }),
|
||||
handler: async (params, { runtime }) => runtime.listGitHubProjects(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.listLabelsBySlug',
|
||||
|
||||
@@ -81,6 +81,14 @@ import {
|
||||
getForgeProviderForRepository
|
||||
} from './forge-provider'
|
||||
|
||||
import { _resetOriginGitHubApiRepositoryCache } from '../github/github-api-repository'
|
||||
|
||||
// The origin-repository cache is module-level state; reset it so slugs
|
||||
// resolved by one test cannot leak into the next.
|
||||
beforeEach(() => {
|
||||
_resetOriginGitHubApiRepositoryCache()
|
||||
})
|
||||
|
||||
describe('forge provider interface', () => {
|
||||
beforeEach(() => {
|
||||
createGitHubPullRequestMock.mockReset()
|
||||
@@ -113,8 +121,9 @@ describe('forge provider interface', () => {
|
||||
// slug parsing returns null. Detection must claim it via the enterprise
|
||||
// resolver instead of falling through to Gitea's demand for ORCA_GITEA_TOKEN.
|
||||
getProjectSlugMock.mockResolvedValue(null)
|
||||
getRepoSlugMock.mockResolvedValue(null)
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValue({
|
||||
// Why: getRepoSlug resolves hosted identities itself now — a GHES remote
|
||||
// comes back host-qualified instead of null + separate enterprise fallback.
|
||||
getRepoSlugMock.mockResolvedValue({
|
||||
owner: 'team',
|
||||
repo: 'orca',
|
||||
host: 'github.acme-corp.com'
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from '../gitea/client'
|
||||
import { createGiteaPullRequest } from '../gitea/pull-request-creation'
|
||||
import { createGitHubPullRequest, getPRForBranchOutcome, getRepoSlug } from '../github/client'
|
||||
import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository'
|
||||
import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client'
|
||||
import { createGitLabMergeRequest } from '../gitlab/merge-request-creation'
|
||||
import {
|
||||
@@ -123,25 +122,11 @@ function unwrapGitHubPRForBranchOutcome(
|
||||
const gitHubForgeProvider = {
|
||||
id: 'github',
|
||||
supportsReviewCreation: true,
|
||||
resolveRepository: async (context) => {
|
||||
const slug = await getRepoSlug(
|
||||
context.repoPath,
|
||||
context.connectionId,
|
||||
...hostedReviewExecutionArgs(context)
|
||||
)
|
||||
if (slug) {
|
||||
return slug
|
||||
}
|
||||
// Why: GHES remotes live on a custom host, so github.com-only slug parsing
|
||||
// misses them and detection would otherwise fall through to Gitea (#8312).
|
||||
// Claim the repo when gh is authenticated to its host — the same signal
|
||||
// GitLab uses for self-hosted instances.
|
||||
return getEnterpriseGitHubRepoSlug(
|
||||
context.repoPath,
|
||||
context.connectionId,
|
||||
...hostedReviewExecutionArgs(context)
|
||||
)
|
||||
},
|
||||
// Why: getRepoSlug resolves hosted identities — GHES remotes are claimed when
|
||||
// gh is authenticated to their host (the same signal GitLab uses for
|
||||
// self-hosted instances), so detection never falls through to Gitea (#8312).
|
||||
resolveRepository: async (context) =>
|
||||
getRepoSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)),
|
||||
async getReviewForBranch(input) {
|
||||
const fallbackReviewNumber =
|
||||
input.linkedReviewNumber == null ? (input.fallbackReviewNumber ?? null) : null
|
||||
|
||||
@@ -117,6 +117,14 @@ vi.mock('./hosted-review', () => ({
|
||||
|
||||
import { getHostedReviewCreationEligibility } from './hosted-review-creation'
|
||||
|
||||
import { _resetOriginGitHubApiRepositoryCache } from '../github/github-api-repository'
|
||||
|
||||
// The origin-repository cache is module-level state; reset it so slugs
|
||||
// resolved by one test cannot leak into the next.
|
||||
beforeEach(() => {
|
||||
_resetOriginGitHubApiRepositoryCache()
|
||||
})
|
||||
|
||||
function resetMocks(): void {
|
||||
for (const mock of [
|
||||
createGitHubPullRequestMock,
|
||||
@@ -155,10 +163,17 @@ function mockGitHubProvider(): void {
|
||||
// resolver claims the repo and reports the host for the gh auth probe (#8312).
|
||||
function mockGitHubEnterpriseProvider(): void {
|
||||
getProjectSlugMock.mockResolvedValue(null)
|
||||
getRepoSlugMock.mockResolvedValue(null)
|
||||
// Why: getRepoSlug resolves hosted identities itself now — a GHES remote
|
||||
// comes back host-qualified instead of null + separate enterprise fallback.
|
||||
getRepoSlugMock.mockResolvedValue({
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
host: 'github.acme-corp.com'
|
||||
})
|
||||
getBitbucketRepoSlugMock.mockResolvedValue(null)
|
||||
getAzureDevOpsRepoSlugMock.mockResolvedValue(null)
|
||||
getGiteaRepoSlugMock.mockResolvedValue(null)
|
||||
// The auth gate still keys off the enterprise resolver (authed-GHES signal).
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValue({
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
|
||||
@@ -117,6 +117,14 @@ vi.mock('./hosted-review', () => ({
|
||||
|
||||
import { createHostedReview } from './hosted-review-creation'
|
||||
|
||||
import { _resetOriginGitHubApiRepositoryCache } from '../github/github-api-repository'
|
||||
|
||||
// The origin-repository cache is module-level state; reset it so slugs
|
||||
// resolved by one test cannot leak into the next.
|
||||
beforeEach(() => {
|
||||
_resetOriginGitHubApiRepositoryCache()
|
||||
})
|
||||
|
||||
function resetMocks(): void {
|
||||
for (const mock of [
|
||||
createGitHubPullRequestMock,
|
||||
@@ -155,10 +163,17 @@ function mockGitHubProvider(): void {
|
||||
// resolver claims the repo and reports the host for the gh auth probe (#8312).
|
||||
function mockGitHubEnterpriseProvider(): void {
|
||||
getProjectSlugMock.mockResolvedValue(null)
|
||||
getRepoSlugMock.mockResolvedValue(null)
|
||||
// Why: getRepoSlug resolves hosted identities itself now — a GHES remote
|
||||
// comes back host-qualified instead of null + separate enterprise fallback.
|
||||
getRepoSlugMock.mockResolvedValue({
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
host: 'github.acme-corp.com'
|
||||
})
|
||||
getBitbucketRepoSlugMock.mockResolvedValue(null)
|
||||
getAzureDevOpsRepoSlugMock.mockResolvedValue(null)
|
||||
getGiteaRepoSlugMock.mockResolvedValue(null)
|
||||
// The auth gate still keys off the enterprise resolver (authed-GHES signal).
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValue({
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
|
||||
@@ -254,6 +254,7 @@ import type {
|
||||
GetProjectViewTableResult,
|
||||
GitHubProjectCommentMutationResult,
|
||||
GitHubProjectMutationResult,
|
||||
ListAccessibleProjectsArgs,
|
||||
ListAccessibleProjectsResult,
|
||||
ListAssignableUsersBySlugArgs,
|
||||
ListAssignableUsersBySlugResult,
|
||||
@@ -1728,7 +1729,9 @@ export type PreloadApi = {
|
||||
*/
|
||||
diagnoseAuth: () => Promise<GhAuthDiagnostic>
|
||||
// ── ProjectV2 (GitHub Projects) ─────────────────────────────────
|
||||
listAccessibleProjects: () => Promise<ListAccessibleProjectsResult>
|
||||
listAccessibleProjects: (
|
||||
args?: ListAccessibleProjectsArgs
|
||||
) => Promise<ListAccessibleProjectsResult>
|
||||
resolveProjectRef: (args: ResolveProjectRefArgs) => Promise<ResolveProjectRefResult>
|
||||
listProjectViews: (args: ListProjectViewsArgs) => Promise<ListProjectViewsResult>
|
||||
getProjectViewTable: (args: GetProjectViewTableArgs) => Promise<GetProjectViewTableResult>
|
||||
|
||||
@@ -110,6 +110,7 @@ import type {
|
||||
GetProjectViewTableResult,
|
||||
GitHubProjectCommentMutationResult,
|
||||
GitHubProjectMutationResult,
|
||||
ListAccessibleProjectsArgs,
|
||||
ListAccessibleProjectsResult,
|
||||
ListAssignableUsersBySlugArgs,
|
||||
ListAssignableUsersBySlugResult,
|
||||
@@ -1538,8 +1539,10 @@ const api = {
|
||||
diagnoseAuth: (): Promise<GhAuthDiagnostic> => ipcRenderer.invoke('gh:diagnoseAuth'),
|
||||
|
||||
// ── ProjectV2 (GitHub Projects) ───────────────────────────────────
|
||||
listAccessibleProjects: (): Promise<ListAccessibleProjectsResult> =>
|
||||
ipcRenderer.invoke('gh:listAccessibleProjects'),
|
||||
listAccessibleProjects: (
|
||||
args?: ListAccessibleProjectsArgs
|
||||
): Promise<ListAccessibleProjectsResult> =>
|
||||
ipcRenderer.invoke('gh:listAccessibleProjects', args),
|
||||
resolveProjectRef: (args: ResolveProjectRefArgs): Promise<ResolveProjectRefResult> =>
|
||||
ipcRenderer.invoke('gh:resolveProjectRef', args),
|
||||
listProjectViews: (args: ListProjectViewsArgs): Promise<ListProjectViewsResult> =>
|
||||
|
||||
@@ -1516,19 +1516,22 @@ function formatPRDelta(item: GitHubWorkItem): string | null {
|
||||
}
|
||||
|
||||
function ReviewChipAvatar({
|
||||
reviewer
|
||||
reviewer,
|
||||
avatarHost
|
||||
}: {
|
||||
reviewer: GitHubPRPrimaryReviewer | null
|
||||
avatarHost?: string
|
||||
}): React.JSX.Element {
|
||||
if (reviewer?.login) {
|
||||
// Why: `gh pr list --json reviewRequests` can return only logins. Prefer the
|
||||
// API avatar_url so GHE renders; GitHubUserAvatar falls back to the login URL
|
||||
// and then an initials placeholder when no avatar loads. See #8784.
|
||||
// Why: review requests can contain only logins; use the PR host for the
|
||||
// fallback while GitHubUserAvatar handles failed images with initials.
|
||||
const avatarUrl =
|
||||
reviewer.avatarUrl || `https://${avatarHost ?? 'github.com'}/${reviewer.login}.png?size=40`
|
||||
return (
|
||||
<GitHubUserAvatar
|
||||
login={reviewer.login}
|
||||
name={reviewer.name}
|
||||
avatarUrl={reviewer.avatarUrl}
|
||||
avatarUrl={avatarUrl}
|
||||
title={reviewer.name ? `${reviewer.name} (${reviewer.login})` : reviewer.login}
|
||||
className="size-5"
|
||||
/>
|
||||
@@ -2523,7 +2526,7 @@ function PRReviewCell({
|
||||
>
|
||||
{primaryReviewer ? (
|
||||
<>
|
||||
<ReviewChipAvatar reviewer={primaryReviewer} />
|
||||
<ReviewChipAvatar reviewer={primaryReviewer} avatarHost={item.prRepo?.host} />
|
||||
{extraReviewerCount > 0 ? (
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||
+{extraReviewerCount}
|
||||
|
||||
@@ -366,7 +366,7 @@ describe('startParkedTerminalByteWatcher', () => {
|
||||
expect.objectContaining({
|
||||
url: 'https://github.com/orca-dev/orca/pull/421',
|
||||
number: 421,
|
||||
slug: { owner: 'orca-dev', repo: 'orca' }
|
||||
slug: { owner: 'orca-dev', repo: 'orca', host: 'github.com' }
|
||||
})
|
||||
)
|
||||
dispose()
|
||||
|
||||
@@ -1060,7 +1060,7 @@ describe('connectPanePty', () => {
|
||||
'wt-1',
|
||||
expect.objectContaining({
|
||||
url: 'https://github.com/acme/orca/pull/42',
|
||||
slug: { owner: 'acme', repo: 'orca' },
|
||||
slug: { owner: 'acme', repo: 'orca', host: 'github.com' },
|
||||
number: 42
|
||||
})
|
||||
)
|
||||
|
||||
@@ -19,6 +19,12 @@ describe('buildGitHubRepoUrl', () => {
|
||||
'https://github.com/stably%20ai/orca%2Ftools'
|
||||
)
|
||||
})
|
||||
|
||||
it('links hosted slugs to their GitHub Enterprise server', () => {
|
||||
expect(buildGitHubRepoUrl({ owner: 'team', repo: 'orca', host: 'github.acme-corp.com' })).toBe(
|
||||
'https://github.acme-corp.com/team/orca'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseGitHubIssueOrPRNumber', () => {
|
||||
@@ -66,7 +72,7 @@ describe('parseGitHubIssueOrPRNumber', () => {
|
||||
describe('parseGitHubIssueOrPRLink', () => {
|
||||
it('parses slug, number, and type for direct item URLs', () => {
|
||||
expect(parseGitHubIssueOrPRLink('https://github.com/stablyai/orca/pull/123')).toEqual({
|
||||
slug: { owner: 'stablyai', repo: 'orca' },
|
||||
slug: { owner: 'stablyai', repo: 'orca', host: 'github.com' },
|
||||
number: 123,
|
||||
type: 'pr'
|
||||
})
|
||||
@@ -74,18 +80,18 @@ describe('parseGitHubIssueOrPRLink', () => {
|
||||
expect(
|
||||
parseGitHubIssueOrPRLink('https://github.my-company.net/MyOrg/my_repo/pull/395')
|
||||
).toEqual({
|
||||
slug: { owner: 'MyOrg', repo: 'my_repo' },
|
||||
slug: { owner: 'MyOrg', repo: 'my_repo', host: 'github.my-company.net' },
|
||||
number: 395,
|
||||
type: 'pr'
|
||||
})
|
||||
|
||||
expect(parseGitHubIssueOrPRLink('https://git.corp.com/MyOrg/my_repo/pull/395')).toEqual({
|
||||
slug: { owner: 'MyOrg', repo: 'my_repo' },
|
||||
slug: { owner: 'MyOrg', repo: 'my_repo', host: 'git.corp.com' },
|
||||
number: 395,
|
||||
type: 'pr'
|
||||
})
|
||||
expect(parseGitHubIssueOrPRLink('https://github.com/stablyai/orca/issues/923')).toEqual({
|
||||
slug: { owner: 'stablyai', repo: 'orca' },
|
||||
slug: { owner: 'stablyai', repo: 'orca', host: 'github.com' },
|
||||
number: 923,
|
||||
type: 'issue'
|
||||
})
|
||||
@@ -93,12 +99,12 @@ describe('parseGitHubIssueOrPRLink', () => {
|
||||
|
||||
it('derives item type from the route segment when trailing segments are present', () => {
|
||||
expect(parseGitHubIssueOrPRLink('https://github.com/o/r/pull/1965/changes')).toEqual({
|
||||
slug: { owner: 'o', repo: 'r' },
|
||||
slug: { owner: 'o', repo: 'r', host: 'github.com' },
|
||||
number: 1965,
|
||||
type: 'pr'
|
||||
})
|
||||
expect(parseGitHubIssueOrPRLink('https://github.com/o/r/issues/923/comments')).toEqual({
|
||||
slug: { owner: 'o', repo: 'r' },
|
||||
slug: { owner: 'o', repo: 'r', host: 'github.com' },
|
||||
number: 923,
|
||||
type: 'issue'
|
||||
})
|
||||
@@ -107,13 +113,13 @@ describe('parseGitHubIssueOrPRLink', () => {
|
||||
it('accepts query, fragment, and repeated trailing slashes', () => {
|
||||
expect(parseGitHubIssueOrPRLink('https://github.com/o/r/pull/1965/files?plain=1#diff')).toEqual(
|
||||
{
|
||||
slug: { owner: 'o', repo: 'r' },
|
||||
slug: { owner: 'o', repo: 'r', host: 'github.com' },
|
||||
number: 1965,
|
||||
type: 'pr'
|
||||
}
|
||||
)
|
||||
expect(parseGitHubIssueOrPRLink('https://github.com/o/r/issues/923/comments///')).toEqual({
|
||||
slug: { owner: 'o', repo: 'r' },
|
||||
slug: { owner: 'o', repo: 'r', host: 'github.com' },
|
||||
number: 923,
|
||||
type: 'issue'
|
||||
})
|
||||
@@ -135,7 +141,7 @@ describe('normalizeGitHubLinkQuery', () => {
|
||||
query: 'https://github.com/stablyai/orca/issues/923',
|
||||
directNumber: 923,
|
||||
directLink: {
|
||||
slug: { owner: 'stablyai', repo: 'orca' },
|
||||
slug: { owner: 'stablyai', repo: 'orca', host: 'github.com' },
|
||||
number: 923,
|
||||
type: 'issue'
|
||||
}
|
||||
@@ -147,7 +153,7 @@ describe('normalizeGitHubLinkQuery', () => {
|
||||
query: 'https://github.com/stablyai/orca/pull/6934',
|
||||
directNumber: 6934,
|
||||
directLink: {
|
||||
slug: { owner: 'stablyai', repo: 'orca' },
|
||||
slug: { owner: 'stablyai', repo: 'orca', host: 'github.com' },
|
||||
number: 6934,
|
||||
type: 'pr'
|
||||
}
|
||||
@@ -159,7 +165,7 @@ describe('normalizeGitHubLinkQuery', () => {
|
||||
query: 'HTTPS://github.com/stablyai/orca/pull/6934',
|
||||
directNumber: 6934,
|
||||
directLink: {
|
||||
slug: { owner: 'stablyai', repo: 'orca' },
|
||||
slug: { owner: 'stablyai', repo: 'orca', host: 'github.com' },
|
||||
number: 6934,
|
||||
type: 'pr'
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i
|
||||
export type RepoSlug = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
}
|
||||
|
||||
export type GitHubIssueOrPRLink = {
|
||||
@@ -18,7 +19,10 @@ export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string |
|
||||
if (!slug?.owner || !slug.repo) {
|
||||
return null
|
||||
}
|
||||
return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}`
|
||||
// Why: hosted identities carry the GHES host; links must point at that
|
||||
// server, not github.com.
|
||||
const host = slug.host ?? 'github.com'
|
||||
return `https://${host}/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}`
|
||||
}
|
||||
|
||||
function matchGitHubItemPath(url: URL): RegExpExecArray | null {
|
||||
@@ -95,7 +99,9 @@ export function parseGitHubIssueOrPRLink(input: string): GitHubIssueOrPRLink | n
|
||||
}
|
||||
|
||||
return {
|
||||
slug: { owner: match[1], repo: match[2] },
|
||||
// Why: the URL proves the host — GHES links must keep their server identity
|
||||
// instead of being treated as github.com slugs.
|
||||
slug: { owner: match[1], repo: match[2], host: url.hostname },
|
||||
type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue',
|
||||
number
|
||||
}
|
||||
|
||||
@@ -331,6 +331,8 @@ export type GetProjectViewTableArgs = {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
/** GitHub host (e.g. GHES); absent means github.com. */
|
||||
host?: string
|
||||
/** View selection precedence: viewId > viewNumber > viewName > first
|
||||
* TABLE_LAYOUT view. */
|
||||
viewId?: string
|
||||
@@ -346,12 +348,14 @@ export type GetProjectViewTableArgs = {
|
||||
export type ProjectWorkItemDetailsBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
number: number
|
||||
type: 'issue' | 'pr'
|
||||
}
|
||||
|
||||
export type UpdateProjectItemFieldArgs = {
|
||||
projectId: string
|
||||
host?: string
|
||||
itemId: string
|
||||
fieldId: string
|
||||
value: GitHubProjectFieldMutationValue
|
||||
@@ -359,6 +363,7 @@ export type UpdateProjectItemFieldArgs = {
|
||||
|
||||
export type ClearProjectItemFieldArgs = {
|
||||
projectId: string
|
||||
host?: string
|
||||
itemId: string
|
||||
fieldId: string
|
||||
}
|
||||
@@ -366,6 +371,7 @@ export type ClearProjectItemFieldArgs = {
|
||||
export type UpdateIssueBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
number: number
|
||||
updates: GitHubIssueUpdate & { body?: string }
|
||||
}
|
||||
@@ -373,6 +379,7 @@ export type UpdateIssueBySlugArgs = {
|
||||
export type UpdatePullRequestBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
number: number
|
||||
updates: { title?: string; body?: string; state?: 'open' | 'closed' }
|
||||
}
|
||||
@@ -380,6 +387,7 @@ export type UpdatePullRequestBySlugArgs = {
|
||||
export type AddIssueCommentBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
number: number
|
||||
body: string
|
||||
}
|
||||
@@ -387,6 +395,7 @@ export type AddIssueCommentBySlugArgs = {
|
||||
export type UpdateIssueCommentBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
commentId: number
|
||||
body: string
|
||||
}
|
||||
@@ -394,28 +403,33 @@ export type UpdateIssueCommentBySlugArgs = {
|
||||
export type DeleteIssueCommentBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
commentId: number
|
||||
}
|
||||
|
||||
export type ListLabelsBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
}
|
||||
|
||||
export type ListAssignableUsersBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
seedLogins?: string[]
|
||||
}
|
||||
|
||||
export type ListIssueTypesBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
}
|
||||
|
||||
export type UpdateIssueTypeBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
number: number
|
||||
/** null clears the issue type. */
|
||||
issueTypeId: string | null
|
||||
@@ -423,10 +437,17 @@ export type UpdateIssueTypeBySlugArgs = {
|
||||
|
||||
export type ResolveProjectRefArgs = {
|
||||
input: string
|
||||
host?: string
|
||||
}
|
||||
|
||||
export type ListProjectViewsArgs = {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
host?: string
|
||||
}
|
||||
|
||||
export type ListAccessibleProjectsArgs = {
|
||||
/** GitHub host (e.g. GHES); absent means github.com. */
|
||||
host?: string
|
||||
}
|
||||
|
||||
@@ -31,10 +31,11 @@ export function faviconUrlFromWebsite(rawUrl: string): string | null {
|
||||
|
||||
// Why: the GitHub owner avatar is the default repo icon, built the same way in
|
||||
// main (auto-detect) and renderer (picker); keep the URL and label in one place.
|
||||
export function githubAvatarIcon(slug: { owner: string; repo: string }): RepoIcon {
|
||||
export function githubAvatarIcon(slug: { owner: string; repo: string; host?: string }): RepoIcon {
|
||||
// Why: GHES serves the same /<login>.png avatar convention as github.com.
|
||||
return {
|
||||
type: 'image',
|
||||
src: `https://github.com/${encodeURIComponent(slug.owner)}.png?size=64`,
|
||||
src: `https://${slug.host ?? 'github.com'}/${encodeURIComponent(slug.owner)}.png?size=64`,
|
||||
source: 'github',
|
||||
label: `${slug.owner}/${slug.repo}`
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
expect(observe('Created https://github.com/acme/orca/pull/42\r\n')).toEqual([
|
||||
{
|
||||
url: 'https://github.com/acme/orca/pull/42',
|
||||
slug: { owner: 'acme', repo: 'orca' },
|
||||
slug: { owner: 'acme', repo: 'orca', host: 'github.com' },
|
||||
number: 42
|
||||
}
|
||||
])
|
||||
@@ -26,7 +26,7 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
expect(observe(`${issue8126Url}\x1b[22m\n`)).toEqual([
|
||||
{
|
||||
url: issue8126Url,
|
||||
slug: { owner: 'owner', repo: 'repo' },
|
||||
slug: { owner: 'owner', repo: 'repo', host: 'github.com' },
|
||||
number: 10
|
||||
}
|
||||
])
|
||||
@@ -39,7 +39,7 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
expect(observe('[22m\n')).toEqual([
|
||||
{
|
||||
url: issue8126Url,
|
||||
slug: { owner: 'owner', repo: 'repo' },
|
||||
slug: { owner: 'owner', repo: 'repo', host: 'github.com' },
|
||||
number: 10
|
||||
}
|
||||
])
|
||||
@@ -73,7 +73,7 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
expect(observe(`${issue8126Url}\x1b[22m\n${issue8126Url}\n`)).toEqual([
|
||||
{
|
||||
url: issue8126Url,
|
||||
slug: { owner: 'owner', repo: 'repo' },
|
||||
slug: { owner: 'owner', repo: 'repo', host: 'github.com' },
|
||||
number: 10
|
||||
}
|
||||
])
|
||||
@@ -86,7 +86,7 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
expect(observe('2\r\n')).toEqual([
|
||||
{
|
||||
url: 'https://github.com/acme/orca/pull/42',
|
||||
slug: { owner: 'acme', repo: 'orca' },
|
||||
slug: { owner: 'acme', repo: 'orca', host: 'github.com' },
|
||||
number: 42
|
||||
}
|
||||
])
|
||||
@@ -99,7 +99,7 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
expect(observe('ub.com/acme/orca/pull/42\n')).toEqual([
|
||||
{
|
||||
url: 'https://github.com/acme/orca/pull/42',
|
||||
slug: { owner: 'acme', repo: 'orca' },
|
||||
slug: { owner: 'acme', repo: 'orca', host: 'github.com' },
|
||||
number: 42
|
||||
}
|
||||
])
|
||||
@@ -132,7 +132,7 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
expect(observe('Created https://github.my-company.net/MyOrg/my_repo/pull/395\r\n')).toEqual([
|
||||
{
|
||||
url: 'https://github.my-company.net/MyOrg/my_repo/pull/395',
|
||||
slug: { owner: 'MyOrg', repo: 'my_repo' },
|
||||
slug: { owner: 'MyOrg', repo: 'my_repo', host: 'github.my-company.net' },
|
||||
number: 395
|
||||
}
|
||||
])
|
||||
@@ -144,7 +144,7 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
expect(observe('Created http://github.internal/MyOrg/my_repo/pull/395\r\n')).toEqual([
|
||||
{
|
||||
url: 'http://github.internal/MyOrg/my_repo/pull/395',
|
||||
slug: { owner: 'MyOrg', repo: 'my_repo' },
|
||||
slug: { owner: 'MyOrg', repo: 'my_repo', host: 'github.internal' },
|
||||
number: 395
|
||||
}
|
||||
])
|
||||
@@ -156,7 +156,8 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
expect(observe('Created https://github.internal:8443/MyOrg/my_repo/pull/397\r\n')).toEqual([
|
||||
{
|
||||
url: 'https://github.internal:8443/MyOrg/my_repo/pull/397',
|
||||
slug: { owner: 'MyOrg', repo: 'my_repo' },
|
||||
// Ports are dropped to match parseGitHubRemoteIdentity's hostname identity.
|
||||
slug: { owner: 'MyOrg', repo: 'my_repo', host: 'github.internal' },
|
||||
number: 397
|
||||
}
|
||||
])
|
||||
@@ -170,7 +171,7 @@ describe('createTerminalGitHubPRLinkDetector', () => {
|
||||
expect(observe(`${noise}Created https://github.com/acme/orca/pull/42\r\n`)).toEqual([
|
||||
{
|
||||
url: 'https://github.com/acme/orca/pull/42',
|
||||
slug: { owner: 'acme', repo: 'orca' },
|
||||
slug: { owner: 'acme', repo: 'orca', host: 'github.com' },
|
||||
number: 42
|
||||
}
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user