mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
fix(github-projects): route gh to the repo's host in multi-host setups (#1715)
GitHub Projects discovery, view fetches, slug mutations, and the gh auth
diagnostic all issued hostless `gh api graphql` / `gh auth status` calls,
so gh fell back to its globally-active host (typically github.com) instead
of the repo's host. In a github.com + GHES setup this made Projects report
a false "missing project scope" error and rejected pasted GHES project URLs.
Thread an optional GitHubRepoTarget {repoPath, connectionId} from the
renderer through IPC/RPC, resolve the repo's git-remote host, and pass
`gh api --hostname <host>` (with cwd for repo-context placeholders). The
host resolver ignores known non-GitHub remotes (GitLab/Bitbucket/Azure) so
ProjectV2 calls are never mis-routed. ProjectPicker now accepts any host so
GHES project URLs parse, and the pasted URL's host overrides the active-repo
hint. Renderer caches are keyed by the routing target so the same
owner/project on different hosts can't reuse each other's data.
Supersedes #2784 (community PR re-implemented against current main).
Fixes #1715
Co-authored-by: Borja <b.llanderas@gmail.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { ghExecFileAsyncMock, gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
gitExecFileAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
import { _resetOwnerRepoCache } from './gh-utils'
|
||||
import { diagnoseGhAuth } from './auth-diagnose'
|
||||
|
||||
describe('diagnoseGhAuth routing', () => {
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
_resetOwnerRepoCache()
|
||||
})
|
||||
|
||||
it('checks auth status for the repo GitHub Enterprise host', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('upstream missing'))
|
||||
.mockResolvedValueOnce({ stdout: 'https://ghe.acme.internal/acme/orca.git\n' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: '',
|
||||
stderr: `ghe.acme.internal
|
||||
✓ Logged in to ghe.acme.internal account octocat (keyring)
|
||||
- Active account: true
|
||||
- Token scopes: 'project', 'read:org', 'repo'
|
||||
`
|
||||
})
|
||||
|
||||
const result = await diagnoseGhAuth({ repoPath: '/repo' })
|
||||
|
||||
expect(result.activeAccount?.host).toBe('ghe.acme.internal')
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['auth', 'status', '--hostname', 'ghe.acme.internal'],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,8 @@
|
||||
*/
|
||||
import { ghExecFileAsync } from '../git/runner'
|
||||
import type { GhAuthDiagnostic, GhAuthAccount } from '../../shared/github-auth-types'
|
||||
import type { GitHubRepoTarget } from '../../shared/github-project-types'
|
||||
import { getGitHubApiHostForRepo, ghRepoExecOptions, githubRepoContext } from './gh-utils'
|
||||
|
||||
// Required scopes for ProjectV2 GraphQL access in Orca. `project` is the
|
||||
// scope that gates ProjectV2 reads/writes; the others are needed for the
|
||||
@@ -90,13 +92,23 @@ export function parseAuthStatus(text: string): GhAuthAccount[] {
|
||||
return accounts
|
||||
}
|
||||
|
||||
export async function diagnoseGhAuth(): Promise<GhAuthDiagnostic> {
|
||||
export async function diagnoseGhAuth(args: GitHubRepoTarget = {}): Promise<GhAuthDiagnostic> {
|
||||
// Why (issue #1715): pass --hostname for the repo's git remote host when
|
||||
// known so auth status reports scopes for the GHES/github.com account that
|
||||
// will actually serve ProjectV2 calls.
|
||||
const ghHost = args.repoPath
|
||||
? await getGitHubApiHostForRepo(args.repoPath, args.connectionId)
|
||||
: null
|
||||
const ghOptions = args.repoPath
|
||||
? ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId))
|
||||
: {}
|
||||
const ghArgs = ['auth', 'status', ...(ghHost ? ['--hostname', ghHost] : [])]
|
||||
let raw = ''
|
||||
let ghAvailable = true
|
||||
try {
|
||||
// `gh auth status` exits non-zero when no host is logged in but still
|
||||
// prints the same diagnostic text we want, so capture both streams.
|
||||
const { stdout, stderr } = await ghExecFileAsync(['auth', 'status'])
|
||||
const { stdout, stderr } = await ghExecFileAsync(ghArgs, ghOptions)
|
||||
raw = `${stdout}\n${stderr}`
|
||||
} catch (err) {
|
||||
const stderr =
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
_resetOwnerRepoCache,
|
||||
classifyGhError,
|
||||
classifyListIssuesError,
|
||||
getGitHubApiHostForRepo,
|
||||
getIssueOwnerRepo,
|
||||
getOwnerRepo,
|
||||
getOwnerRepoForRemote,
|
||||
@@ -89,6 +90,28 @@ describe('github owner/repo resolution', () => {
|
||||
expect(parseGitHubOwnerRepo('https://ghe.acme.internal/acme/orca.git')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves a GitHub Enterprise host for gh api routing', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('upstream missing'))
|
||||
.mockResolvedValueOnce({ stdout: 'https://ghe.acme.internal/acme/orca.git\n' })
|
||||
|
||||
await expect(getGitHubApiHostForRepo('/repo')).resolves.toBe('ghe.acme.internal')
|
||||
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'upstream'], {
|
||||
cwd: '/repo'
|
||||
})
|
||||
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], {
|
||||
cwd: '/repo'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not route gh api to known non-GitHub hosts', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('upstream missing'))
|
||||
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:acme/orca.git\n' })
|
||||
|
||||
await expect(getGitHubApiHostForRepo('/repo')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps getOwnerRepo origin-based', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: 'git@github.com:fork/orca.git\n'
|
||||
|
||||
@@ -7,6 +7,7 @@ import { gitExecFileAsync, ghExecFileAsync, extractExecError } from '../git/runn
|
||||
export const execFileAsync = promisify(execFile)
|
||||
export { ghExecFileAsync, gitExecFileAsync, extractExecError }
|
||||
export { classifyGhError, classifyListIssuesError } from './gh-error-classification'
|
||||
export { getGitHubApiHostForRepo } from './github-api-host-resolution'
|
||||
export {
|
||||
_getOwnerRepoCacheSize,
|
||||
_resetOwnerRepoCache,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
normalizeGitHubApiHost,
|
||||
parseGitHubRemoteIdentity,
|
||||
preferredGitHubApiHost
|
||||
} from './github-remote-identity-parsing'
|
||||
import { getRemoteUrlForRepo, githubRepoContext } from './github-repository-identity'
|
||||
|
||||
// Why (issue #1715): mirror the owner/repo positive-cache window so a repo's
|
||||
// resolved gh API host and its owner/repo lookups expire on the same cadence.
|
||||
const REPO_HOST_CACHE_TTL_MS = 30_000
|
||||
|
||||
const repoHostCache = new Map<string, { value: string | null; expiresAt: number }>()
|
||||
|
||||
/** @internal - exposed for tests only */
|
||||
export function _resetRepoHostCache(): void {
|
||||
repoHostCache.clear()
|
||||
}
|
||||
|
||||
// Why (issue #1715): in multi-host setups gh must target the repo's own host.
|
||||
// Prefer the upstream remote's host, then origin's, so forks of a GHE repo
|
||||
// route to the enterprise host rather than the user's default github.com.
|
||||
export async function getGitHubApiHostForRepo(
|
||||
repoPath: string,
|
||||
connectionId?: string | null
|
||||
): Promise<string | null> {
|
||||
const context = githubRepoContext(repoPath, connectionId)
|
||||
const cacheKey = `${context.connectionId ?? 'local'}\0${context.repoPath}\0api-host`
|
||||
const cached = repoHostCache.get(cacheKey)
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.value
|
||||
}
|
||||
if (cached) {
|
||||
repoHostCache.delete(cacheKey)
|
||||
}
|
||||
|
||||
let fallback: string | null = null
|
||||
for (const remoteName of ['upstream', 'origin']) {
|
||||
try {
|
||||
const remoteUrl = await getRemoteUrlForRepo(context, remoteName)
|
||||
const identity = remoteUrl ? parseGitHubRemoteIdentity(remoteUrl) : null
|
||||
const host = identity ? normalizeGitHubApiHost(identity.host) : null
|
||||
if (!host) {
|
||||
continue
|
||||
}
|
||||
if (preferredGitHubApiHost(host)) {
|
||||
repoHostCache.set(cacheKey, {
|
||||
value: host,
|
||||
expiresAt: Date.now() + REPO_HOST_CACHE_TTL_MS
|
||||
})
|
||||
return host
|
||||
}
|
||||
fallback ??= host
|
||||
} catch {
|
||||
// ignore missing remotes or non-git paths
|
||||
}
|
||||
}
|
||||
|
||||
repoHostCache.set(cacheKey, {
|
||||
value: fallback,
|
||||
expiresAt: Date.now() + REPO_HOST_CACHE_TTL_MS
|
||||
})
|
||||
return fallback
|
||||
}
|
||||
@@ -47,3 +47,35 @@ export function parseGitHubOwnerRepo(remoteUrl: string): GitHubOwnerRepo | null
|
||||
}
|
||||
return { owner: identity.owner, repo: identity.repo }
|
||||
}
|
||||
|
||||
// Why (issue #1715): validate and normalize a git remote host into a `gh api
|
||||
// --hostname` value. ProjectV2 is GitHub-only, so reject hosts that obviously
|
||||
// belong to other providers rather than mis-routing them to a GHES API.
|
||||
export function normalizeGitHubApiHost(host: string): string | null {
|
||||
const normalized = host.trim().toLowerCase()
|
||||
// Reject a leading dash so the host can never be mistaken for a gh CLI flag.
|
||||
if (!/^[a-z0-9]([a-z0-9.-]*)?(?::[0-9]+)?$/.test(normalized)) {
|
||||
return null
|
||||
}
|
||||
const hostname = normalized.split(':')[0]
|
||||
if (
|
||||
hostname === 'gitlab.com' ||
|
||||
hostname.endsWith('.gitlab.com') ||
|
||||
hostname === 'bitbucket.org' ||
|
||||
hostname.endsWith('.bitbucket.org') ||
|
||||
hostname === 'dev.azure.com' ||
|
||||
hostname.endsWith('.visualstudio.com')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// Why (issue #1715): a heuristic for which remote host most likely hosts the
|
||||
// GitHub API. github.com is canonical; otherwise a host containing "github" or
|
||||
// "ghe" is almost certainly a GitHub Enterprise instance and is preferred over
|
||||
// an unrecognized host (which is only used as a fallback).
|
||||
export function preferredGitHubApiHost(host: string): boolean {
|
||||
const hostname = host.split(':')[0]
|
||||
return hostname === 'github.com' || hostname.includes('github') || hostname.includes('ghe')
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type GitHubRemoteIdentity
|
||||
} from './github-remote-identity-parsing'
|
||||
import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error'
|
||||
import { _resetRepoHostCache } from './github-api-host-resolution'
|
||||
|
||||
export type OwnerRepo = GitHubOwnerRepo
|
||||
|
||||
@@ -66,6 +67,7 @@ const ownerRepoInFlight = new Map<string, Promise<OwnerRepo | null>>()
|
||||
export function _resetOwnerRepoCache(): void {
|
||||
ownerRepoCache.clear()
|
||||
ownerRepoInFlight.clear()
|
||||
_resetRepoHostCache()
|
||||
}
|
||||
|
||||
/** @internal - exposed for tests only */
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { ghExecFileAsyncMock, gitExecFileAsyncMock, rateLimitGuardMock, noteRateLimitSpendMock } =
|
||||
vi.hoisted(() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
rateLimitGuardMock: vi.fn(() => ({ blocked: false })),
|
||||
noteRateLimitSpendMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
extractExecError: (err: unknown) => ({
|
||||
stderr: err && typeof err === 'object' && 'stderr' in err ? String(err.stderr) : '',
|
||||
stdout: err && typeof err === 'object' && 'stdout' in err ? String(err.stdout) : ''
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./rate-limit', () => ({
|
||||
rateLimitGuard: rateLimitGuardMock,
|
||||
noteRateLimitSpend: noteRateLimitSpendMock
|
||||
}))
|
||||
|
||||
import { _resetOwnerRepoCache } from './gh-utils'
|
||||
import {
|
||||
listAccessibleProjects,
|
||||
updateIssueBySlug,
|
||||
updateProjectItemFieldValue
|
||||
} from './project-view'
|
||||
|
||||
function discoveryResponses(): { stdout: string; stderr: string }[] {
|
||||
return [
|
||||
{
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
viewer: {
|
||||
login: 'octocat',
|
||||
projectsV2: { pageInfo: { hasNextPage: false }, nodes: [] }
|
||||
}
|
||||
}
|
||||
}),
|
||||
stderr: ''
|
||||
},
|
||||
{
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
viewer: {
|
||||
organizations: { pageInfo: { hasNextPage: false }, nodes: [] }
|
||||
}
|
||||
}
|
||||
}),
|
||||
stderr: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('GitHub Project gh routing', () => {
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
rateLimitGuardMock.mockReset()
|
||||
rateLimitGuardMock.mockReturnValue({ blocked: false })
|
||||
noteRateLimitSpendMock.mockReset()
|
||||
_resetOwnerRepoCache()
|
||||
})
|
||||
|
||||
it('passes --hostname for GitHub Enterprise repo targets', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('upstream missing'))
|
||||
.mockResolvedValueOnce({ stdout: 'https://ghe.acme.internal/acme/orca.git\n' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce(discoveryResponses()[0])
|
||||
.mockResolvedValueOnce(discoveryResponses()[1])
|
||||
|
||||
await expect(listAccessibleProjects({ repoPath: '/repo' })).resolves.toMatchObject({
|
||||
ok: true
|
||||
})
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalled()
|
||||
for (const [argv, options] of ghExecFileAsyncMock.mock.calls) {
|
||||
expect(argv).toEqual(expect.arrayContaining(['--hostname', 'ghe.acme.internal']))
|
||||
expect(options).toMatchObject({ cwd: '/repo' })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not pass known non-GitHub remote hosts as gh api hostnames', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('upstream missing'))
|
||||
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:acme/orca.git\n' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce(discoveryResponses()[0])
|
||||
.mockResolvedValueOnce(discoveryResponses()[1])
|
||||
|
||||
await expect(listAccessibleProjects({ repoPath: '/repo' })).resolves.toMatchObject({
|
||||
ok: true
|
||||
})
|
||||
|
||||
const firstArgv = ghExecFileAsyncMock.mock.calls[0][0] as string[]
|
||||
expect(firstArgv).not.toContain('--hostname')
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][1]).toMatchObject({ cwd: '/repo' })
|
||||
})
|
||||
|
||||
it('passes --hostname for Project field and slug mutations', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('upstream missing'))
|
||||
.mockResolvedValueOnce({ stdout: 'https://ghe.acme.internal/acme/orca.git\n' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ data: { updateProjectV2ItemFieldValue: {} } }),
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await expect(
|
||||
updateProjectItemFieldValue({
|
||||
repoPath: '/repo',
|
||||
projectId: 'PVT_project',
|
||||
itemId: 'PVTI_item',
|
||||
fieldId: 'PVTF_field',
|
||||
value: { kind: 'text', text: 'Ready' }
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][0]).toEqual(
|
||||
expect.arrayContaining(['--hostname', 'ghe.acme.internal'])
|
||||
)
|
||||
|
||||
ghExecFileAsyncMock.mockClear()
|
||||
gitExecFileAsyncMock.mockClear()
|
||||
_resetOwnerRepoCache()
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('upstream missing'))
|
||||
.mockResolvedValueOnce({ stdout: 'https://ghe.acme.internal/acme/orca.git\n' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '{}', stderr: '' })
|
||||
|
||||
await expect(
|
||||
updateIssueBySlug({
|
||||
repoPath: '/repo',
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
number: 12,
|
||||
updates: { title: 'New title' }
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][0]).toEqual(
|
||||
expect.arrayContaining(['--hostname', 'ghe.acme.internal'])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -136,7 +136,8 @@ describe('parseProjectPaste', () => {
|
||||
kind: 'org',
|
||||
owner: 'acme',
|
||||
number: 42,
|
||||
viewNumber: 3
|
||||
viewNumber: 3,
|
||||
host: 'github.com'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -144,7 +145,23 @@ describe('parseProjectPaste', () => {
|
||||
expect(parseProjectPaste('https://github.com/users/octocat/projects/1')).toEqual({
|
||||
kind: 'user',
|
||||
owner: 'octocat',
|
||||
number: 1
|
||||
number: 1,
|
||||
host: 'github.com'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses GitHub Enterprise project URL hosts', () => {
|
||||
expect(parseProjectPaste('https://ghe/orgs/acme/projects/2')).toEqual({
|
||||
kind: 'org',
|
||||
owner: 'acme',
|
||||
number: 2,
|
||||
host: 'ghe'
|
||||
})
|
||||
expect(parseProjectPaste('https://ghe.acme.internal:8443/users/octocat/projects/3')).toEqual({
|
||||
kind: 'user',
|
||||
owner: 'octocat',
|
||||
number: 3,
|
||||
host: 'ghe.acme.internal:8443'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -152,6 +169,10 @@ describe('parseProjectPaste', () => {
|
||||
expect(parseProjectPaste('https://github.com/orgs/co_op/projects/1')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects URLs whose host has invalid characters', () => {
|
||||
expect(parseProjectPaste('https://github.com@evil.example/orgs/acme/projects/1')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty input', () => {
|
||||
expect(parseProjectPaste('')).toBeNull()
|
||||
expect(parseProjectPaste(' ')).toBeNull()
|
||||
|
||||
+183
-107
@@ -19,6 +19,8 @@ import {
|
||||
isValidOwnerSlug,
|
||||
assertSlug,
|
||||
assertPositiveInt,
|
||||
targetToGhApiRoute,
|
||||
type GhApiRoute,
|
||||
type GhGraphqlErrorShape,
|
||||
type GraphqlVars
|
||||
} from './project-view/internals'
|
||||
@@ -41,6 +43,7 @@ import type {
|
||||
GitHubProjectViewError,
|
||||
GitHubProjectViewLayout,
|
||||
GitHubProjectViewSummary,
|
||||
ListAccessibleProjectsArgs,
|
||||
ListAccessibleProjectsResult,
|
||||
ListProjectViewsArgs,
|
||||
ListProjectViewsResult,
|
||||
@@ -638,12 +641,15 @@ function ownerQueryRoot(ownerType: GitHubProjectOwnerType): string {
|
||||
return ownerType === 'organization' ? 'organization' : 'user'
|
||||
}
|
||||
|
||||
async function fetchProjectViewsPage(args: {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
after: string | null
|
||||
}): Promise<
|
||||
async function fetchProjectViewsPage(
|
||||
args: {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
after: string | null
|
||||
},
|
||||
route: GhApiRoute
|
||||
): Promise<
|
||||
| {
|
||||
ok: true
|
||||
project: { id: string; title: string; url: string }
|
||||
@@ -686,7 +692,8 @@ async function fetchProjectViewsPage(args: {
|
||||
}
|
||||
const res = await runGraphql<Record<string, { projectV2?: RawProjectConfig | null } | null>>(
|
||||
query,
|
||||
vars
|
||||
vars,
|
||||
route
|
||||
)
|
||||
if (!res.ok) {
|
||||
return res
|
||||
@@ -709,7 +716,8 @@ async function fetchProjectViewsPage(args: {
|
||||
|
||||
async function fetchViewFieldsContinuation(
|
||||
viewId: string,
|
||||
after: string
|
||||
after: string,
|
||||
route: GhApiRoute
|
||||
): Promise<
|
||||
{ ok: true; fields: RawProjectV2Field[] } | { ok: false; error: GitHubProjectViewError }
|
||||
> {
|
||||
@@ -745,7 +753,7 @@ async function fetchViewFieldsContinuation(
|
||||
nodes?: (RawProjectV2Field | null)[]
|
||||
}
|
||||
} | null
|
||||
}>(query, { viewId, after: cursor })
|
||||
}>(query, { viewId, after: cursor }, route)
|
||||
if (!res.ok) {
|
||||
return res
|
||||
}
|
||||
@@ -847,15 +855,18 @@ type RawItemsPage = {
|
||||
// Why: runGraphql returns the classified error but not the raw GraphQL
|
||||
// errors; for the parent-field retry decision we need those. This variant
|
||||
// returns the raw envelope so callers can re-inspect.
|
||||
async function fetchItemsPageWithRaw(args: {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
query: string
|
||||
first: number
|
||||
after: string | null
|
||||
includeParent: boolean
|
||||
}): Promise<
|
||||
async function fetchItemsPageWithRaw(
|
||||
args: {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
query: string
|
||||
first: number
|
||||
after: string | null
|
||||
includeParent: boolean
|
||||
},
|
||||
route: GhApiRoute
|
||||
): Promise<
|
||||
| { ok: true; page: RawItemsPage }
|
||||
| {
|
||||
ok: false
|
||||
@@ -887,7 +898,13 @@ async function fetchItemsPageWithRaw(args: {
|
||||
}
|
||||
${FIELD_CONFIG_FRAGMENT}
|
||||
`
|
||||
const argsArr: string[] = ['api', 'graphql', '-f', `query=${query}`]
|
||||
const argsArr: string[] = [
|
||||
'api',
|
||||
...(route.hostname ? ['--hostname', route.hostname] : []),
|
||||
'graphql',
|
||||
'-f',
|
||||
`query=${query}`
|
||||
]
|
||||
argsArr.push('-f', `owner=${args.owner}`)
|
||||
argsArr.push('-F', `num=${args.projectNumber}`)
|
||||
argsArr.push('-f', `q=${args.query}`)
|
||||
@@ -912,7 +929,10 @@ async function fetchItemsPageWithRaw(args: {
|
||||
let stderr = ''
|
||||
let execFailed = false
|
||||
try {
|
||||
const r = await ghExecFileAsync(argsArr, { encoding: 'utf-8' })
|
||||
const r = await ghExecFileAsync(argsArr, {
|
||||
encoding: 'utf-8',
|
||||
...(route.cwd ? { cwd: route.cwd } : {})
|
||||
})
|
||||
stdout = r.stdout
|
||||
stderr = r.stderr
|
||||
} catch (err) {
|
||||
@@ -978,12 +998,15 @@ async function fetchItemsPageWithRaw(args: {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllItems(args: {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
query: string
|
||||
}): Promise<
|
||||
async function fetchAllItems(
|
||||
args: {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
query: string
|
||||
},
|
||||
route: GhApiRoute
|
||||
): Promise<
|
||||
| { ok: true; rows: GitHubProjectRow[]; totalCount: number; parentFieldDropped: boolean }
|
||||
| { ok: false; error: GitHubProjectViewError; totalCount?: number }
|
||||
> {
|
||||
@@ -1017,15 +1040,18 @@ async function fetchAllItems(args: {
|
||||
parentFieldProbeInFlight.set(scopeKey, probe)
|
||||
probePromise = (async () => {
|
||||
try {
|
||||
const result = await fetchItemsPageWithRaw({
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
query: args.query,
|
||||
first: ITEM_PAGE_SIZE,
|
||||
after: null,
|
||||
includeParent: true
|
||||
})
|
||||
const result = await fetchItemsPageWithRaw(
|
||||
{
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
query: args.query,
|
||||
first: ITEM_PAGE_SIZE,
|
||||
after: null,
|
||||
includeParent: true
|
||||
},
|
||||
route
|
||||
)
|
||||
// Why: flip parentFieldRetriedByOwner BEFORE resolveProbe()/clearing
|
||||
// parentFieldProbeInFlight so siblings that awoke on `inFlight.catch()`
|
||||
// observe the updated flag. Doing this in the outer block (after
|
||||
@@ -1043,15 +1069,18 @@ async function fetchAllItems(args: {
|
||||
})()
|
||||
first = await probePromise
|
||||
} else {
|
||||
first = await fetchItemsPageWithRaw({
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
query: args.query,
|
||||
first: ITEM_PAGE_SIZE,
|
||||
after: null,
|
||||
includeParent
|
||||
})
|
||||
first = await fetchItemsPageWithRaw(
|
||||
{
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
query: args.query,
|
||||
first: ITEM_PAGE_SIZE,
|
||||
after: null,
|
||||
includeParent
|
||||
},
|
||||
route
|
||||
)
|
||||
}
|
||||
if (!first.ok && includeParent && errorsIndicateParentField(first.rawErrors, first.stderr)) {
|
||||
// Retry the whole table without parent. Mark this owner as retried so
|
||||
@@ -1066,15 +1095,18 @@ async function fetchAllItems(args: {
|
||||
)
|
||||
markParentFieldWarningLogged(scopeKey)
|
||||
}
|
||||
first = await fetchItemsPageWithRaw({
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
query: args.query,
|
||||
first: ITEM_PAGE_SIZE,
|
||||
after: null,
|
||||
includeParent: false
|
||||
})
|
||||
first = await fetchItemsPageWithRaw(
|
||||
{
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
query: args.query,
|
||||
first: ITEM_PAGE_SIZE,
|
||||
after: null,
|
||||
includeParent: false
|
||||
},
|
||||
route
|
||||
)
|
||||
}
|
||||
if (!first.ok) {
|
||||
return { ok: false, error: first.error }
|
||||
@@ -1133,15 +1165,18 @@ async function fetchAllItems(args: {
|
||||
}
|
||||
}
|
||||
while (hasNext) {
|
||||
const next = await fetchItemsPageWithRaw({
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
query: args.query,
|
||||
first: ITEM_PAGE_SIZE,
|
||||
after: cursor as string,
|
||||
includeParent
|
||||
})
|
||||
const next = await fetchItemsPageWithRaw(
|
||||
{
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
query: args.query,
|
||||
first: ITEM_PAGE_SIZE,
|
||||
after: cursor as string,
|
||||
includeParent
|
||||
},
|
||||
route
|
||||
)
|
||||
if (!next.ok) {
|
||||
return { ok: false, error: next.error, totalCount }
|
||||
}
|
||||
@@ -1174,12 +1209,15 @@ async function fetchAllItems(args: {
|
||||
|
||||
// ─── Cheap count-only query (for unsupported_layout) ──────────────────
|
||||
|
||||
async function fetchItemsCountOnly(args: {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
query: string
|
||||
}): Promise<number | null> {
|
||||
async function fetchItemsCountOnly(
|
||||
args: {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
query: string
|
||||
},
|
||||
route: GhApiRoute
|
||||
): Promise<number | null> {
|
||||
const root = ownerQueryRoot(args.ownerType)
|
||||
const query = `
|
||||
query($owner:String!, $num:Int!, $q:String!) {
|
||||
@@ -1192,7 +1230,7 @@ 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 }, route)
|
||||
if (!res.ok) {
|
||||
return null
|
||||
}
|
||||
@@ -1219,6 +1257,7 @@ export async function getProjectViewTable(
|
||||
error: { type: 'validation_error', message: 'Invalid ownerType.' }
|
||||
}
|
||||
}
|
||||
const ghRoute = await targetToGhApiRoute(args)
|
||||
|
||||
// Paginate views until a match is found.
|
||||
let cursor: string | null = null
|
||||
@@ -1227,12 +1266,15 @@ export async function getProjectViewTable(
|
||||
let matchStrength: 'id' | 'number' | 'name' | 'default' | null = null
|
||||
const viewsSeen: RawProjectView[] = []
|
||||
while (true) {
|
||||
const page = await fetchProjectViewsPage({
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
after: cursor
|
||||
})
|
||||
const page = await fetchProjectViewsPage(
|
||||
{
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
after: cursor
|
||||
},
|
||||
ghRoute
|
||||
)
|
||||
if (!page.ok) {
|
||||
return { ok: false, error: page.error }
|
||||
}
|
||||
@@ -1287,7 +1329,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, ghRoute)
|
||||
if (!cont.ok) {
|
||||
return { ok: false, error: cont.error }
|
||||
}
|
||||
@@ -1310,12 +1352,15 @@ export async function getProjectViewTable(
|
||||
// Unsupported layout: return without paginating items; attempt a cheap
|
||||
// count-only query best-effort.
|
||||
if (selectedView.layout !== 'TABLE_LAYOUT') {
|
||||
const count = await fetchItemsCountOnly({
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
query: effectiveQuery
|
||||
})
|
||||
const count = await fetchItemsCountOnly(
|
||||
{
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
query: effectiveQuery
|
||||
},
|
||||
ghRoute
|
||||
)
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
@@ -1327,12 +1372,15 @@ export async function getProjectViewTable(
|
||||
}
|
||||
|
||||
// Fetch items.
|
||||
const items = await fetchAllItems({
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
query: effectiveQuery
|
||||
})
|
||||
const items = await fetchAllItems(
|
||||
{
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
query: effectiveQuery
|
||||
},
|
||||
ghRoute
|
||||
)
|
||||
if (!items.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -1386,7 +1434,10 @@ type RawViewerDiscovery = {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAccessibleProjects(): Promise<ListAccessibleProjectsResult> {
|
||||
export async function listAccessibleProjects(
|
||||
args: ListAccessibleProjectsArgs = {}
|
||||
): Promise<ListAccessibleProjectsResult> {
|
||||
const ghRoute = await targetToGhApiRoute(args)
|
||||
const viewerProjects: GitHubProjectSummary[] = []
|
||||
const orgProjects: GitHubProjectSummary[] = []
|
||||
// Why: per-org failures are collected so the picker can render a "some orgs
|
||||
@@ -1421,7 +1472,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, ghRoute)
|
||||
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 +1547,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, ghRoute)
|
||||
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
|
||||
@@ -1560,8 +1611,8 @@ export async function listAccessibleProjects(): Promise<ListAccessibleProjectsRe
|
||||
// ─── resolveProjectRef ─────────────────────────────────────────────────
|
||||
|
||||
type ParsedPaste =
|
||||
| { kind: 'org'; owner: string; number: number; viewNumber?: number }
|
||||
| { kind: 'user'; owner: string; number: number; viewNumber?: number }
|
||||
| { kind: 'org'; owner: string; number: number; viewNumber?: number; host: string }
|
||||
| { kind: 'user'; owner: string; number: number; viewNumber?: number; host: string }
|
||||
| { kind: 'bare'; owner: string; number: number }
|
||||
|
||||
export function parseProjectPaste(input: string): ParsedPaste | null {
|
||||
@@ -1572,12 +1623,17 @@ 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 (issue #1715): accept any host so GHES project URLs (e.g.
|
||||
// https://ghe.example.com/orgs/foo/projects/3) parse. The host is captured
|
||||
// so callers can route gh against the right server with `gh api --hostname`.
|
||||
const urlRe = /^https?:\/\/([^/\s]+)\/(orgs|users)\/([^/]+)\/projects\/(\d+)(?:\/views\/(\d+))?/i
|
||||
const m = trimmed.match(urlRe)
|
||||
if (m) {
|
||||
const [, kindSeg, owner, nStr, vStr] = m
|
||||
const [, rawHost, kindSeg, owner, nStr, vStr] = m
|
||||
const host = normalizeProjectUrlHost(rawHost)
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
const number = parseInt(nStr, 10)
|
||||
if (!Number.isInteger(number) || number < 1) {
|
||||
return null
|
||||
@@ -1590,6 +1646,7 @@ export function parseProjectPaste(input: string): ParsedPaste | null {
|
||||
kind: kindSeg === 'orgs' ? 'org' : 'user',
|
||||
owner,
|
||||
number,
|
||||
host,
|
||||
...(viewNumber !== undefined && Number.isInteger(viewNumber) && viewNumber >= 1
|
||||
? { viewNumber }
|
||||
: {})
|
||||
@@ -1608,9 +1665,16 @@ export function parseProjectPaste(input: string): ParsedPaste | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeProjectUrlHost(host: string): string | null {
|
||||
const normalized = host.trim().toLowerCase()
|
||||
// Reject a leading dash so a pasted host can never be mistaken for a gh flag.
|
||||
return /^[a-z0-9]([a-z0-9.-]*)?(?::[0-9]+)?$/.test(normalized) ? normalized : null
|
||||
}
|
||||
|
||||
async function resolveOwnerType(
|
||||
owner: string,
|
||||
preferred: GitHubProjectOwnerType | null
|
||||
preferred: GitHubProjectOwnerType | null,
|
||||
route: GhApiRoute
|
||||
): Promise<
|
||||
| { ok: true; ownerType: GitHubProjectOwnerType; title: string }
|
||||
| { ok: false; error: GitHubProjectViewError }
|
||||
@@ -1638,7 +1702,7 @@ async function resolveOwnerType(
|
||||
}
|
||||
const res = await runGraphql<
|
||||
Record<string, { projectV2?: { id?: string; title?: string } | null; login?: string } | null>
|
||||
>(query, vars)
|
||||
>(query, vars, route)
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: res.error }
|
||||
}
|
||||
@@ -1718,8 +1782,16 @@ export async function resolveProjectRef(
|
||||
}
|
||||
const preferred: GitHubProjectOwnerType | null =
|
||||
parsed.kind === 'org' ? 'organization' : parsed.kind === 'user' ? 'user' : null
|
||||
const baseRoute = await targetToGhApiRoute(args)
|
||||
// Why: pasted project URLs carry an explicit host. Prefer that over the
|
||||
// active repo hint so users can paste a GHES URL even when the active repo
|
||||
// context is absent or points at another GitHub host.
|
||||
const ghRoute =
|
||||
parsed.kind === 'bare'
|
||||
? baseRoute
|
||||
: { ...baseRoute, hostname: parsed.host.trim().toLowerCase() }
|
||||
// Verify by fetching project title.
|
||||
const ownerRes = await resolveOwnerType(parsed.owner, preferred)
|
||||
const ownerRes = await resolveOwnerType(parsed.owner, preferred, ghRoute)
|
||||
if (!ownerRes.ok) {
|
||||
return { ok: false, error: ownerRes.error }
|
||||
}
|
||||
@@ -1732,7 +1804,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 }, ghRoute)
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: res.error }
|
||||
}
|
||||
@@ -1771,15 +1843,19 @@ export async function listProjectViews(
|
||||
if (args.ownerType !== 'organization' && args.ownerType !== 'user') {
|
||||
return { ok: false, error: { type: 'validation_error', message: 'Invalid ownerType.' } }
|
||||
}
|
||||
const ghRoute = await targetToGhApiRoute(args)
|
||||
const summaries: GitHubProjectViewSummary[] = []
|
||||
let cursor: string | null = null
|
||||
while (true) {
|
||||
const page = await fetchProjectViewsPage({
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
after: cursor
|
||||
})
|
||||
const page = await fetchProjectViewsPage(
|
||||
{
|
||||
owner: args.owner,
|
||||
ownerType: args.ownerType,
|
||||
projectNumber: args.projectNumber,
|
||||
after: cursor
|
||||
},
|
||||
ghRoute
|
||||
)
|
||||
if (!page.ok) {
|
||||
return { ok: false, error: page.error }
|
||||
}
|
||||
|
||||
@@ -6,14 +6,51 @@ classification semantics. */
|
||||
// for gh calls. The legacy plain `execFileAsync` is NOT used here — routing
|
||||
// every gh call through the runner gives us transient-5xx retry, WSL path
|
||||
// translation, and a single hook point for future quota tracking.
|
||||
import { acquire, release } from '../gh-utils'
|
||||
import {
|
||||
acquire,
|
||||
getGitHubApiHostForRepo,
|
||||
ghRepoExecOptions,
|
||||
githubRepoContext,
|
||||
release
|
||||
} from '../gh-utils'
|
||||
import { extractExecError, ghExecFileAsync } from '../../git/runner'
|
||||
import { rateLimitGuard, noteRateLimitSpend, type RateLimitBucketKind } from '../rate-limit'
|
||||
import type { GitHubProjectViewError } from '../../../shared/github-project-types'
|
||||
import type { GitHubProjectViewError, GitHubRepoTarget } from '../../../shared/github-project-types'
|
||||
|
||||
export { acquire, release, extractExecError, ghExecFileAsync, rateLimitGuard, noteRateLimitSpend }
|
||||
export type { RateLimitBucketKind }
|
||||
|
||||
export type GhApiRoute = { cwd?: string; hostname?: string }
|
||||
|
||||
// Why (issue #1715): `gh api graphql` does not reliably infer the API host
|
||||
// from cwd for hostless GraphQL calls, so resolve the repo's git remote host
|
||||
// and pass it as `gh api --hostname`. Keep cwd for placeholder expansion and
|
||||
// for commands that still consult local repo context.
|
||||
export async function targetToGhApiRoute(
|
||||
target: GitHubRepoTarget | undefined
|
||||
): Promise<GhApiRoute> {
|
||||
if (!target?.repoPath) {
|
||||
return {}
|
||||
}
|
||||
const cwd = ghRepoExecOptions(githubRepoContext(target.repoPath, target.connectionId)).cwd
|
||||
const hostname = await getGitHubApiHostForRepo(target.repoPath, target.connectionId)
|
||||
return {
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(hostname ? { hostname } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGhApiRoute(route?: GhApiRoute | string): GhApiRoute {
|
||||
if (!route) {
|
||||
return {}
|
||||
}
|
||||
return typeof route === 'string' ? { cwd: route } : route
|
||||
}
|
||||
|
||||
function ghApiArgs(route: GhApiRoute, endpoint: string): string[] {
|
||||
return ['api', ...(route.hostname ? ['--hostname', route.hostname] : []), endpoint]
|
||||
}
|
||||
|
||||
// ─── Slug validation ──────────────────────────────────────────────────
|
||||
|
||||
// Why: GitHub usernames/org logins disallow `_`, `.`, leading `-`. Repo names
|
||||
@@ -269,7 +306,7 @@ export type GraphqlVars = Record<string, string | number | boolean>
|
||||
export async function runGraphql<T>(
|
||||
query: string,
|
||||
vars: GraphqlVars,
|
||||
cwd?: string
|
||||
route?: GhApiRoute | string
|
||||
): Promise<
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; error: GitHubProjectViewError; raw: { stderr: string; stdout: string } }
|
||||
@@ -281,7 +318,8 @@ export async function runGraphql<T>(
|
||||
// Why: build argv as an array. `-f` for strings (including numbers passed
|
||||
// as strings), `-F` coerces to typed. We use `-f` uniformly and coerce in
|
||||
// the query via Int! casts, because `gh` can confuse empty strings.
|
||||
const args: string[] = ['api', 'graphql', '-f', `query=${query}`]
|
||||
const ghRoute = normalizeGhApiRoute(route)
|
||||
const args: string[] = [...ghApiArgs(ghRoute, 'graphql'), '-f', `query=${query}`]
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
if (typeof v === 'number' || typeof v === 'boolean') {
|
||||
args.push('-F', `${k}=${String(v)}`)
|
||||
@@ -294,7 +332,7 @@ export async function runGraphql<T>(
|
||||
try {
|
||||
const { stdout, stderr } = await ghExecFileAsync(args, {
|
||||
encoding: 'utf-8',
|
||||
...(cwd ? { cwd } : {})
|
||||
...(ghRoute.cwd ? { cwd: ghRoute.cwd } : {})
|
||||
})
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as { data?: T; errors?: GhGraphqlErrorShape[] }
|
||||
@@ -338,7 +376,7 @@ export async function runGraphql<T>(
|
||||
|
||||
export async function runRest<T>(
|
||||
args: string[],
|
||||
cwd?: string,
|
||||
route?: GhApiRoute | string,
|
||||
bucket: RateLimitBucketKind = 'core',
|
||||
options?: { expectEmpty?: boolean }
|
||||
): Promise<{ ok: true; data: T } | { ok: false; error: GitHubProjectViewError }> {
|
||||
@@ -348,11 +386,15 @@ export async function runRest<T>(
|
||||
}
|
||||
await acquire()
|
||||
noteRateLimitSpend(bucket)
|
||||
const ghRoute = normalizeGhApiRoute(route)
|
||||
try {
|
||||
const { stdout, stderr } = await ghExecFileAsync(['api', ...args], {
|
||||
encoding: 'utf-8',
|
||||
...(cwd ? { cwd } : {})
|
||||
})
|
||||
const { stdout, stderr } = await ghExecFileAsync(
|
||||
[...ghApiArgs(ghRoute, args[0]), ...args.slice(1)],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
...(ghRoute.cwd ? { cwd: ghRoute.cwd } : {})
|
||||
}
|
||||
)
|
||||
// Why: 204/empty-body endpoints (DELETE label, DELETE comment) return no
|
||||
// body. Treat empty stdout as success rather than misclassifying the
|
||||
// unparseable response as 'unknown' — which the caller would otherwise
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock('./internals', () => ({
|
||||
rateLimitedError: () => ({ type: 'rate_limited', message: 'rate limited' }),
|
||||
runGraphql: vi.fn(),
|
||||
runRest: runRestMock,
|
||||
targetToGhApiRoute: vi.fn(async () => ({})),
|
||||
validateSlugArgs: (owner: string, repo: string) =>
|
||||
owner && repo ? { ok: true } : { ok: false, error: { type: 'validation_error' } },
|
||||
assertPositiveInt: (value: number, name: string) =>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
runRest,
|
||||
validateSlugArgs,
|
||||
assertPositiveInt,
|
||||
targetToGhApiRoute,
|
||||
type GraphqlVars
|
||||
} from './internals'
|
||||
import type { GitHubAssignableUser, GitHubWorkItemDetails, PRComment } from '../../../shared/types'
|
||||
@@ -122,7 +123,8 @@ export async function updateProjectItemFieldValue(
|
||||
fieldId: args.fieldId,
|
||||
value: valVar.val
|
||||
}
|
||||
const res = await runGraphql<unknown>(query, vars)
|
||||
const route = await targetToGhApiRoute(args)
|
||||
const res = await runGraphql<unknown>(query, vars, route)
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: res.error }
|
||||
}
|
||||
@@ -144,11 +146,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
|
||||
},
|
||||
await targetToGhApiRoute(args)
|
||||
)
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: res.error }
|
||||
}
|
||||
@@ -182,6 +188,7 @@ export async function updateIssueBySlug(
|
||||
addAssignees,
|
||||
removeAssignees
|
||||
} = args.updates
|
||||
const route = await targetToGhApiRoute(args)
|
||||
|
||||
if (duplicateOf !== undefined && (state !== 'closed' || stateReason !== 'duplicate')) {
|
||||
return {
|
||||
@@ -229,7 +236,12 @@ export async function updateIssueBySlug(
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
await ghExecFileAsync(stateArgs, { encoding: 'utf-8' })
|
||||
// Why (issue #1715): `gh issue close/reopen` has no --hostname flag, so
|
||||
// run it with the repo's cwd to let gh infer the GHES/github.com host.
|
||||
await ghExecFileAsync(stateArgs, {
|
||||
encoding: 'utf-8',
|
||||
...(route.cwd ? { cwd: route.cwd } : {})
|
||||
})
|
||||
} catch (err) {
|
||||
const { stderr, stdout } = extractExecError(err)
|
||||
return { ok: false, error: classifyProjectError(stderr, stdout) }
|
||||
@@ -247,7 +259,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, route)
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
@@ -263,7 +275,7 @@ 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`], route)
|
||||
if (!fetched.ok) {
|
||||
return { ok: false, error: fetched.error }
|
||||
}
|
||||
@@ -281,7 +293,7 @@ export async function updateIssueBySlug(
|
||||
// body — GitHub does NOT interpret that as "clear labels". The
|
||||
// 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', {
|
||||
const r = await runRest<unknown>(['-X', 'DELETE', `${base}/labels`], route, 'core', {
|
||||
expectEmpty: true
|
||||
})
|
||||
if (!r.ok && r.error.type !== 'not_found') {
|
||||
@@ -292,7 +304,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, route)
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
@@ -303,7 +315,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, route)
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
@@ -311,7 +323,7 @@ export async function updateIssueBySlug(
|
||||
if (removeCount === 1) {
|
||||
const r = await runRest<unknown>(
|
||||
['-X', 'DELETE', `${base}/labels/${encodeURIComponent(removeLabels![0])}`],
|
||||
undefined,
|
||||
route,
|
||||
'core',
|
||||
{ expectEmpty: true }
|
||||
)
|
||||
@@ -328,7 +340,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, route)
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
@@ -338,7 +350,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, route)
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
@@ -384,7 +396,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, await targetToGhApiRoute(args))
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
@@ -425,13 +437,16 @@ 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}`
|
||||
],
|
||||
await targetToGhApiRoute(args)
|
||||
)
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
@@ -452,13 +467,16 @@ 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}`
|
||||
],
|
||||
await targetToGhApiRoute(args)
|
||||
)
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
}
|
||||
@@ -478,7 +496,7 @@ export async function deleteIssueCommentBySlug(
|
||||
}
|
||||
const r = await runRest<unknown>(
|
||||
['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`],
|
||||
undefined,
|
||||
await targetToGhApiRoute(args),
|
||||
'core',
|
||||
{ expectEmpty: true }
|
||||
)
|
||||
@@ -501,14 +519,22 @@ export async function listLabelsBySlug(
|
||||
if (guard.blocked) {
|
||||
return { ok: false, error: rateLimitedError(guard) }
|
||||
}
|
||||
const route = await targetToGhApiRoute(args)
|
||||
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')
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'],
|
||||
{ encoding: 'utf-8' }
|
||||
[
|
||||
'api',
|
||||
...(route.hostname ? ['--hostname', route.hostname] : []),
|
||||
'--paginate',
|
||||
`repos/${args.owner}/${args.repo}/labels`,
|
||||
'--jq',
|
||||
'.[].name'
|
||||
],
|
||||
{ encoding: 'utf-8', ...(route.cwd ? { cwd: route.cwd } : {}) }
|
||||
)
|
||||
return {
|
||||
ok: true,
|
||||
@@ -539,18 +565,20 @@ export async function listAssignableUsersBySlug(
|
||||
if (guard.blocked) {
|
||||
return { ok: false, error: rateLimitedError(guard) }
|
||||
}
|
||||
const route = await targetToGhApiRoute(args)
|
||||
await acquire()
|
||||
noteRateLimitSpend('core')
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...(route.hostname ? ['--hostname', route.hostname] : []),
|
||||
'--paginate',
|
||||
`repos/${args.owner}/${args.repo}/assignees`,
|
||||
'--jq',
|
||||
'.[] | {login: .login, name: null, avatarUrl: .avatar_url}'
|
||||
],
|
||||
{ encoding: 'utf-8' }
|
||||
{ encoding: 'utf-8', ...(route.cwd ? { cwd: route.cwd } : {}) }
|
||||
)
|
||||
for (const line of stdout
|
||||
.trim()
|
||||
@@ -614,7 +642,7 @@ export async function listIssueTypesBySlug(
|
||||
} | null)[]
|
||||
} | null
|
||||
} | null
|
||||
}>(query, { owner: args.owner, repo: args.repo })
|
||||
}>(query, { owner: args.owner, repo: args.repo }, await targetToGhApiRoute(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 +687,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 },
|
||||
await targetToGhApiRoute(args)
|
||||
)
|
||||
if (!lookup.ok) {
|
||||
return { ok: false, error: lookup.error }
|
||||
@@ -689,7 +718,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, await targetToGhApiRoute(args))
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: res.error }
|
||||
}
|
||||
@@ -810,7 +839,11 @@ 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 },
|
||||
await targetToGhApiRoute(args)
|
||||
)
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: res.error }
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ import type {
|
||||
ClearProjectItemFieldArgs,
|
||||
DeleteIssueCommentBySlugArgs,
|
||||
GetProjectViewTableArgs,
|
||||
GitHubRepoTarget,
|
||||
ListAccessibleProjectsArgs,
|
||||
ListAssignableUsersBySlugArgs,
|
||||
ListIssueTypesBySlugArgs,
|
||||
ListLabelsBySlugArgs,
|
||||
@@ -1122,7 +1124,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
getRateLimit(args?.force ? { force: true } : undefined)
|
||||
)
|
||||
|
||||
ipcMain.handle('gh:diagnoseAuth', () => diagnoseGhAuth())
|
||||
ipcMain.handle('gh:diagnoseAuth', (_event, args?: GitHubRepoTarget) => diagnoseGhAuth(args))
|
||||
|
||||
// ── GitHub ProjectV2 view handlers ─────────────────────────────────
|
||||
// Why: registered unconditionally so enabling the experimental flag at
|
||||
@@ -1130,7 +1132,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)
|
||||
|
||||
@@ -525,6 +525,7 @@ import {
|
||||
import type {
|
||||
ClearProjectItemFieldArgs,
|
||||
GetProjectViewTableArgs,
|
||||
ListAccessibleProjectsArgs,
|
||||
ListAssignableUsersBySlugArgs,
|
||||
ListIssueTypesBySlugArgs,
|
||||
ListLabelsBySlugArgs,
|
||||
@@ -11073,8 +11074,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(
|
||||
|
||||
@@ -45,10 +45,6 @@ const SlugRepo = z.object({
|
||||
repo: requiredString('Missing repo')
|
||||
})
|
||||
|
||||
const SlugAssignableUsers = SlugRepo.extend({
|
||||
seedLogins: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
const PrForBranch = RepoSelector.extend({
|
||||
branch: requiredString('Missing branch'),
|
||||
linkedPRNumber: z.number().int().positive().nullable().optional(),
|
||||
@@ -198,7 +194,22 @@ const PRReviewCommentReply = RepoSelector.extend({
|
||||
|
||||
const ProjectOwnerType = z.enum(['organization', 'user'])
|
||||
|
||||
const ProjectViewTable = z.object({
|
||||
// Why (issue #1715): repo target lets the renderer hint which gh host to
|
||||
// route to in multi-host setups. Both fields optional — callers without a
|
||||
// repo context (e.g. paste-to-add from a foreign host) accept gh's globally
|
||||
// active host. `repoPath` lets main resolve the git remote host for
|
||||
// `gh api --hostname`; local repos also use it as cwd for repo placeholders.
|
||||
const RepoTarget = z.object({
|
||||
repoPath: OptionalString,
|
||||
connectionId: OptionalString.nullable().optional()
|
||||
})
|
||||
|
||||
const RoutedSlugRepo = RepoTarget.merge(SlugRepo)
|
||||
const RoutedSlugAssignableUsers = RoutedSlugRepo.extend({
|
||||
seedLogins: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
const ProjectViewTable = RepoTarget.extend({
|
||||
owner: requiredString('Missing owner'),
|
||||
ownerType: ProjectOwnerType,
|
||||
projectNumber: z.number().int().positive(),
|
||||
@@ -208,44 +219,40 @@ const ProjectViewTable = z.object({
|
||||
queryOverride: OptionalString
|
||||
})
|
||||
|
||||
const ProjectWorkItemDetailsBySlug = SlugRepo.extend({
|
||||
const ProjectWorkItemDetailsBySlug = RoutedSlugRepo.extend({
|
||||
number: z.number().int().positive(),
|
||||
type: z.enum(['issue', 'pr'])
|
||||
})
|
||||
|
||||
const ProjectRef = z.object({
|
||||
const ProjectRef = RepoTarget.extend({
|
||||
input: requiredString('Missing project reference')
|
||||
})
|
||||
|
||||
const ProjectViews = z.object({
|
||||
const ProjectViews = RepoTarget.extend({
|
||||
owner: requiredString('Missing owner'),
|
||||
ownerType: ProjectOwnerType,
|
||||
projectNumber: z.number().int().positive()
|
||||
})
|
||||
|
||||
const ProjectItemField = z.object({
|
||||
const ProjectItemField = RepoTarget.extend({
|
||||
projectId: requiredString('Missing project ID'),
|
||||
itemId: requiredString('Missing item ID'),
|
||||
fieldId: requiredString('Missing field ID'),
|
||||
value: z.any()
|
||||
})
|
||||
|
||||
const ClearProjectItemField = z.object({
|
||||
const ClearProjectItemField = RepoTarget.extend({
|
||||
projectId: requiredString('Missing project ID'),
|
||||
itemId: requiredString('Missing item ID'),
|
||||
fieldId: requiredString('Missing field ID')
|
||||
})
|
||||
|
||||
const SlugIssueUpdate = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
const SlugIssueUpdate = RoutedSlugRepo.extend({
|
||||
number: z.number().int().positive(),
|
||||
updates: IssueUpdate
|
||||
})
|
||||
|
||||
const SlugPullRequestUpdate = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
const SlugPullRequestUpdate = RoutedSlugRepo.extend({
|
||||
number: z.number().int().positive(),
|
||||
updates: z.object({
|
||||
state: z.enum(['open', 'closed']).optional(),
|
||||
@@ -254,30 +261,22 @@ const SlugPullRequestUpdate = z.object({
|
||||
})
|
||||
})
|
||||
|
||||
const SlugIssueTypeUpdate = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
const SlugIssueTypeUpdate = RoutedSlugRepo.extend({
|
||||
number: z.number().int().positive(),
|
||||
issueTypeId: z.string().nullable()
|
||||
})
|
||||
|
||||
const SlugIssueComment = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
const SlugIssueComment = RoutedSlugRepo.extend({
|
||||
number: z.number().int().positive(),
|
||||
body: requiredString('Comment body required')
|
||||
})
|
||||
|
||||
const SlugIssueCommentEdit = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
const SlugIssueCommentEdit = RoutedSlugRepo.extend({
|
||||
commentId: z.number().int().positive(),
|
||||
body: requiredString('Comment body required')
|
||||
})
|
||||
|
||||
const SlugIssueCommentDelete = z.object({
|
||||
owner: requiredString('Missing owner'),
|
||||
repo: requiredString('Missing repo'),
|
||||
const SlugIssueCommentDelete = RoutedSlugRepo.extend({
|
||||
commentId: z.number().int().positive()
|
||||
})
|
||||
|
||||
@@ -542,22 +541,22 @@ export const GITHUB_METHODS: RpcMethod[] = [
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.listAccessible',
|
||||
params: z.object({}),
|
||||
handler: async (_params, { runtime }) => runtime.listGitHubProjects()
|
||||
params: RepoTarget,
|
||||
handler: async (params, { runtime }) => runtime.listGitHubProjects(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.listLabelsBySlug',
|
||||
params: SlugRepo,
|
||||
params: RoutedSlugRepo,
|
||||
handler: async (params, { runtime }) => runtime.listGitHubLabelsBySlug(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.listAssignableUsersBySlug',
|
||||
params: SlugAssignableUsers,
|
||||
params: RoutedSlugAssignableUsers,
|
||||
handler: async (params, { runtime }) => runtime.listGitHubAssignableUsersBySlug(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'github.project.listIssueTypesBySlug',
|
||||
params: SlugRepo,
|
||||
params: RoutedSlugRepo,
|
||||
handler: async (params, { runtime }) => runtime.listGitHubIssueTypesBySlug(params)
|
||||
}),
|
||||
defineMethod({
|
||||
|
||||
@@ -202,6 +202,8 @@ import type {
|
||||
GetProjectViewTableResult,
|
||||
GitHubProjectCommentMutationResult,
|
||||
GitHubProjectMutationResult,
|
||||
GitHubRepoTarget,
|
||||
ListAccessibleProjectsArgs,
|
||||
ListAccessibleProjectsResult,
|
||||
ListAssignableUsersBySlugArgs,
|
||||
ListAssignableUsersBySlugResult,
|
||||
@@ -1385,9 +1387,11 @@ export type PreloadApi = {
|
||||
* shell and silently shadows the keyring credential — in that case
|
||||
* `gh auth refresh` is a no-op and the UI must say so.
|
||||
*/
|
||||
diagnoseAuth: () => Promise<GhAuthDiagnostic>
|
||||
diagnoseAuth: (args?: GitHubRepoTarget) => 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>
|
||||
|
||||
@@ -90,6 +90,8 @@ import type {
|
||||
GetProjectViewTableResult,
|
||||
GitHubProjectCommentMutationResult,
|
||||
GitHubProjectMutationResult,
|
||||
GitHubRepoTarget,
|
||||
ListAccessibleProjectsArgs,
|
||||
ListAccessibleProjectsResult,
|
||||
ListAssignableUsersBySlugArgs,
|
||||
ListAssignableUsersBySlugResult,
|
||||
@@ -1247,11 +1249,14 @@ const api = {
|
||||
rateLimit: (args?: { force?: boolean }): Promise<GetRateLimitResult> =>
|
||||
ipcRenderer.invoke('gh:rateLimit', args),
|
||||
|
||||
diagnoseAuth: (): Promise<GhAuthDiagnostic> => ipcRenderer.invoke('gh:diagnoseAuth'),
|
||||
diagnoseAuth: (args?: GitHubRepoTarget): Promise<GhAuthDiagnostic> =>
|
||||
ipcRenderer.invoke('gh:diagnoseAuth', args),
|
||||
|
||||
// ── 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> =>
|
||||
|
||||
@@ -180,6 +180,7 @@ import {
|
||||
getGithubWorkItemWorkspaceAttachmentLabel
|
||||
} from '@/lib/github-work-item-workspace-attachment'
|
||||
import { startFixChecksAgent } from '@/lib/fix-checks-agent-launch'
|
||||
import { activeGitHubRepoTargetFromState } from '@/lib/github-active-repo-target'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { buildFixBrokenChecksPrompt, getBrokenChecks } from '@/components/pr-checks-fix-prompt'
|
||||
import type {
|
||||
@@ -734,6 +735,25 @@ function PRReviewersPanel({
|
||||
: repoOwnerSettings,
|
||||
[repoOwnerSettings, sourceContext]
|
||||
)
|
||||
// Why (issue #1715): the slug-addressed reviewer/label/assignee lookups need
|
||||
// the repo-host routing hint so they resolve against the host that owns the
|
||||
// dialog's repo (github.com vs GHES).
|
||||
const repoForTarget = useAppStore((s) =>
|
||||
repoPath
|
||||
? (s.repos.find((repo) => repo.path === repoPath) ?? null)
|
||||
: s.activeRepoId
|
||||
? (s.repos.find((repo) => repo.id === s.activeRepoId) ?? null)
|
||||
: null
|
||||
)
|
||||
const repoTarget = useMemo(
|
||||
() =>
|
||||
repoForTarget
|
||||
? { repoPath: repoForTarget.path, connectionId: repoForTarget.connectionId ?? null }
|
||||
: repoPath
|
||||
? { repoPath }
|
||||
: {},
|
||||
[repoForTarget, repoPath]
|
||||
)
|
||||
const reviewerInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const reviewerInputFocusFrameRef = useRef<number | null>(null)
|
||||
const reviewerPanelMountedRef = useRef(true)
|
||||
@@ -808,7 +828,8 @@ function PRReviewersPanel({
|
||||
open && reviewSlug ? reviewSlug.owner : null,
|
||||
open && reviewSlug ? reviewSlug.repo : null,
|
||||
reviewerSeedUsers.map((user) => user.login),
|
||||
sourceSettings
|
||||
sourceSettings,
|
||||
repoTarget
|
||||
)
|
||||
const reviewerMetadataByPath = useRepoAssignees(
|
||||
open && !reviewSlug ? repoPath : null,
|
||||
@@ -4891,8 +4912,10 @@ async function runIssueUpdate(args: {
|
||||
updates: Parameters<typeof window.api.gh.updateIssue>[0]['updates']
|
||||
}): Promise<void> {
|
||||
if (args.projectOrigin) {
|
||||
const state = useAppStore.getState()
|
||||
const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.repoId))
|
||||
const updateArgs = {
|
||||
...activeGitHubRepoTargetFromState(state),
|
||||
owner: args.projectOrigin.owner,
|
||||
repo: args.projectOrigin.repo,
|
||||
number: args.number,
|
||||
@@ -4944,8 +4967,10 @@ async function runWorkItemBodyUpdate(args: {
|
||||
if (!targetSlug) {
|
||||
throw new Error('No GitHub repository context available for this pull request.')
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.item.repoId))
|
||||
const updateArgs = {
|
||||
...activeGitHubRepoTargetFromState(state),
|
||||
owner: targetSlug.owner,
|
||||
repo: targetSlug.repo,
|
||||
number: args.item.number,
|
||||
@@ -4987,8 +5012,10 @@ async function runPullRequestStateUpdate(args: {
|
||||
updates: { state: 'open' | 'closed' }
|
||||
}): Promise<void> {
|
||||
if (args.projectOrigin) {
|
||||
const state = useAppStore.getState()
|
||||
const target = getActiveRuntimeTarget(getGitHubMutationSettings(args.repoId))
|
||||
const updateArgs = {
|
||||
...activeGitHubRepoTargetFromState(state),
|
||||
owner: args.projectOrigin.owner,
|
||||
repo: args.projectOrigin.repo,
|
||||
number: args.number,
|
||||
@@ -5142,6 +5169,27 @@ function GHEditSection({
|
||||
: repoOwnerSettings,
|
||||
[repoOwnerSettings, sourceContext]
|
||||
)
|
||||
// Why (issue #1715): the slug-addressed label/assignee lookups need the
|
||||
// repo-host routing hint so they resolve against the host that owns the
|
||||
// dialog's repo (github.com vs GHES).
|
||||
const repoForTarget = useAppStore((s) =>
|
||||
repoId
|
||||
? (s.repos.find((repo) => repo.id === repoId) ?? null)
|
||||
: repoPath
|
||||
? (s.repos.find((repo) => repo.path === repoPath) ?? null)
|
||||
: s.activeRepoId
|
||||
? (s.repos.find((repo) => repo.id === s.activeRepoId) ?? null)
|
||||
: null
|
||||
)
|
||||
const repoTarget = useMemo(
|
||||
() =>
|
||||
repoForTarget
|
||||
? { repoPath: repoForTarget.path, connectionId: repoForTarget.connectionId ?? null }
|
||||
: repoPath
|
||||
? { repoPath }
|
||||
: {},
|
||||
[repoForTarget, repoPath]
|
||||
)
|
||||
const { isPending, run } = useImmediateMutation()
|
||||
// Why: when the dialog opens from a Project view, mutations route through
|
||||
// *BySlug IPCs and we must keep `projectViewCache` in sync alongside
|
||||
@@ -5168,7 +5216,7 @@ function GHEditSection({
|
||||
projectOrigin ? null : repoId,
|
||||
sourceSettings
|
||||
)
|
||||
const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo, sourceSettings)
|
||||
const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo, sourceSettings, repoTarget)
|
||||
const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath
|
||||
const repositoryLabelsUrl = useMemo(() => getGitHubRepositoryLabelsUrl(item.url), [item.url])
|
||||
const repoAssigneesByPath = useRepoAssignees(
|
||||
@@ -5176,7 +5224,13 @@ function GHEditSection({
|
||||
projectOrigin ? null : repoId,
|
||||
sourceSettings
|
||||
)
|
||||
const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees, sourceSettings)
|
||||
const repoAssigneesBySlug = useRepoAssigneesBySlug(
|
||||
slugOwner,
|
||||
slugRepo,
|
||||
assignees,
|
||||
sourceSettings,
|
||||
repoTarget
|
||||
)
|
||||
const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath
|
||||
const hasAttachedWorkspace =
|
||||
attachedWorkspaceLabel !== null && attachedWorkspaceLabel !== undefined
|
||||
|
||||
@@ -44,7 +44,11 @@ describe('GitHubItemDialog source host boundaries', () => {
|
||||
|
||||
expect(section).toContain('getTaskSourceRuntimeSettings(sourceContext)')
|
||||
expect(section).toContain('useRepoLabels(')
|
||||
expect(section).toContain('useRepoLabelsBySlug(slugOwner, slugRepo, sourceSettings)')
|
||||
// Why (issue #1715): the slug lookups gained a trailing repoTarget arg for
|
||||
// multi-host gh routing; sourceSettings must still be the source-routing arg.
|
||||
expect(section).toContain(
|
||||
'useRepoLabelsBySlug(slugOwner, slugRepo, sourceSettings, repoTarget)'
|
||||
)
|
||||
expect(section).toContain('useRepoAssignees(')
|
||||
expect(section).toContain('useRepoAssigneesBySlug(')
|
||||
expect(section).toContain('sourceSettings')
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
* a one-shot diagnostic and rewrites the suggested fix to match what gh
|
||||
* is actually doing: env-shadow vs. plain missing-scope vs. not-installed.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Copy, ExternalLink, RotateCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { GitHubProjectViewError } from '@/../../shared/github-project-types'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { GitHubProjectViewError, GitHubRepoTarget } from '@/../../shared/github-project-types'
|
||||
import type { GhAuthDiagnostic } from '@/../../shared/github-auth-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
@@ -248,10 +249,29 @@ export function GhAuthErrorHelp({
|
||||
variant?: 'block' | 'banner'
|
||||
}): React.JSX.Element {
|
||||
const [diag, setDiag] = useState<GhAuthDiagnostic | null>(null)
|
||||
// Why (issue #1715): pass the active repo as a host hint so `gh auth status`
|
||||
// reads scope/auth info for the host that owns the repo instead of gh's
|
||||
// globally-active host. Without this, multi-host setups (github.com + GHES)
|
||||
// see remediation tailored to the wrong host's account.
|
||||
const activeRepoId = useAppStore((s) => s.activeRepoId)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const activeRepoTarget = useMemo<GitHubRepoTarget>(() => {
|
||||
const repo = activeRepoId ? repos.find((r) => r.id === activeRepoId) : null
|
||||
if (!repo) {
|
||||
return {}
|
||||
}
|
||||
return { repoPath: repo.path, connectionId: repo.connectionId ?? null }
|
||||
}, [activeRepoId, repos])
|
||||
const repoPath = activeRepoTarget.repoPath
|
||||
const connectionId = activeRepoTarget.connectionId
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const target: GitHubRepoTarget = {
|
||||
...(repoPath ? { repoPath } : {}),
|
||||
...(connectionId !== undefined ? { connectionId } : {})
|
||||
}
|
||||
window.api.gh
|
||||
.diagnoseAuth()
|
||||
.diagnoseAuth(target)
|
||||
.then((d) => {
|
||||
if (!cancelled) {
|
||||
setDiag(d)
|
||||
@@ -262,7 +282,7 @@ export function GhAuthErrorHelp({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [repoPath, connectionId])
|
||||
|
||||
const remedy = buildRemediation(error.message, error.type, diag)
|
||||
const docsUrl = remedy.docsUrl
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useAppStore } from '@/store'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { useRepoSlugIndex } from '@/lib/repo-slug-index'
|
||||
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
|
||||
import { activeGitHubRepoTargetFromState } from '@/lib/github-active-repo-target'
|
||||
import type {
|
||||
GitHubIssueType,
|
||||
GitHubProjectField,
|
||||
@@ -323,6 +324,15 @@ function IssueTypeCell({
|
||||
const [open, setOpen] = useState(false)
|
||||
const [options, setOptions] = useState<GitHubIssueType[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
// Why (issue #1715): route the issue-type lookup to the host that owns the
|
||||
// active repo. Memoized on primitive ids so a fresh target per render
|
||||
// doesn't refire the effect.
|
||||
const activeRepoId = useAppStore((s) => s.activeRepoId)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const repoTarget = React.useMemo(
|
||||
() => activeGitHubRepoTargetFromState({ activeRepoId, repos }),
|
||||
[activeRepoId, repos]
|
||||
)
|
||||
const [owner, repo] = (row.content.repository ?? '').split('/')
|
||||
const { lookupSlug } = useRepoSlugIndex()
|
||||
const matchedRepo = useMemo(
|
||||
@@ -340,15 +350,16 @@ function IssueTypeCell({
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
const target = getActiveRuntimeTarget(matchedRepo ? ownerSettings : sourceSettings)
|
||||
const args = { ...repoTarget, owner, repo }
|
||||
const request =
|
||||
target.kind === 'environment'
|
||||
? callRuntimeRpc<ListIssueTypesBySlugResult>(
|
||||
target,
|
||||
'github.project.listIssueTypesBySlug',
|
||||
{ owner, repo },
|
||||
args,
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: window.api.gh.listIssueTypesBySlug({ owner, repo })
|
||||
: window.api.gh.listIssueTypesBySlug(args)
|
||||
request
|
||||
.then((res) => {
|
||||
if (cancelled) {
|
||||
@@ -366,7 +377,7 @@ function IssueTypeCell({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [matchedRepo, open, owner, ownerSettings, repo, sourceSettings])
|
||||
}, [matchedRepo, open, owner, ownerSettings, repo, sourceSettings, repoTarget])
|
||||
|
||||
const trigger = (
|
||||
<span className="inline-flex items-center gap-1 text-xs">
|
||||
@@ -828,6 +839,14 @@ function AssigneesCell({
|
||||
}): React.JSX.Element {
|
||||
const assignees = row.content.assignees
|
||||
const [open, setOpen] = useState(false)
|
||||
// Why (issue #1715): route the assignee lookup to the host that owns the
|
||||
// active repo. Memoized on primitive ids to keep the IPC stable.
|
||||
const activeRepoId = useAppStore((s) => s.activeRepoId)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const repoTarget = React.useMemo(
|
||||
() => activeGitHubRepoTargetFromState({ activeRepoId, repos }),
|
||||
[activeRepoId, repos]
|
||||
)
|
||||
|
||||
const [owner, repo] = (row.content.repository ?? '').split('/')
|
||||
|
||||
@@ -849,7 +868,8 @@ function AssigneesCell({
|
||||
open ? owner : null,
|
||||
open ? repo : null,
|
||||
seedKey ? seedKey.split(',') : [],
|
||||
sourceSettings
|
||||
sourceSettings,
|
||||
repoTarget
|
||||
)
|
||||
|
||||
const labelContent =
|
||||
@@ -943,9 +963,22 @@ function LabelsCell({
|
||||
}): React.JSX.Element {
|
||||
const labels = row.content.labels
|
||||
const [open, setOpen] = useState(false)
|
||||
// Why (issue #1715): route the label lookup to the host that owns the
|
||||
// active repo. Memoized on primitive ids to keep the IPC stable.
|
||||
const activeRepoId = useAppStore((s) => s.activeRepoId)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const repoTarget = React.useMemo(
|
||||
() => activeGitHubRepoTargetFromState({ activeRepoId, repos }),
|
||||
[activeRepoId, repos]
|
||||
)
|
||||
|
||||
const [owner, repo] = (row.content.repository ?? '').split('/')
|
||||
const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, sourceSettings)
|
||||
const metadata = useRepoLabelsBySlug(
|
||||
open ? owner : null,
|
||||
open ? repo : null,
|
||||
sourceSettings,
|
||||
repoTarget
|
||||
)
|
||||
|
||||
const labelContent =
|
||||
labels.length === 0 ? null : labels.map((l) => <LabelChip key={l.name} label={l} />)
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
GitHubProjectSummary,
|
||||
GitHubProjectViewError,
|
||||
GitHubProjectViewSummary,
|
||||
GitHubRepoTarget,
|
||||
ListAccessibleProjectsResult,
|
||||
ListProjectViewsResult,
|
||||
ResolveProjectRefResult
|
||||
@@ -47,6 +48,12 @@ type Props = {
|
||||
title?: string
|
||||
} | null
|
||||
onSelect: (selection: ResolvedProjectSelection) => void
|
||||
// Why (issue #1715): repo target hint so the picker's gh calls land on the
|
||||
// right host in multi-host setups (e.g. github.com + GHES). The wrapping
|
||||
// ProjectViewWrapper derives this from the active worktree's owning repo;
|
||||
// main resolves the gh API host from that repo's git remote. Omit both to
|
||||
// accept gh's globally-active host (legitimate when no repo context exists).
|
||||
repoTarget?: GitHubRepoTarget
|
||||
}
|
||||
|
||||
const BROWSE_CACHE_TTL_MS = 5 * 60_000
|
||||
@@ -56,6 +63,9 @@ type BrowseCacheEntry = {
|
||||
partialFailures?: { owner: string; message: string }[]
|
||||
}
|
||||
|
||||
// Why (issue #1715): the cache is keyed by both the runtime scope AND the
|
||||
// repo-target routing key so a GHES vs github.com switch (or runtime switch)
|
||||
// can't serve projects discovered against the wrong host.
|
||||
const browseCacheByRuntimeScope = new Map<string, BrowseCacheEntry>()
|
||||
|
||||
function getProjectPickerRuntimeScope(
|
||||
@@ -65,51 +75,72 @@ function getProjectPickerRuntimeScope(
|
||||
return target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local'
|
||||
}
|
||||
|
||||
function repoTargetCacheKey(repoTarget: GitHubRepoTarget): string {
|
||||
return `${repoTarget.connectionId ?? 'local'}:${repoTarget.repoPath ?? ''}`
|
||||
}
|
||||
|
||||
async function listAccessibleProjectsForRuntime(
|
||||
settings: Parameters<typeof getActiveRuntimeTarget>[0]
|
||||
settings: Parameters<typeof getActiveRuntimeTarget>[0],
|
||||
repoTarget: GitHubRepoTarget
|
||||
): Promise<ListAccessibleProjectsResult> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
return target.kind === 'environment'
|
||||
? callRuntimeRpc<ListAccessibleProjectsResult>(
|
||||
target,
|
||||
'github.project.listAccessible',
|
||||
{},
|
||||
repoTarget,
|
||||
{ timeoutMs: 60_000 }
|
||||
)
|
||||
: window.api.gh.listAccessibleProjects()
|
||||
: window.api.gh.listAccessibleProjects(repoTarget)
|
||||
}
|
||||
|
||||
async function listProjectViewsForRuntime(
|
||||
settings: Parameters<typeof getActiveRuntimeTarget>[0],
|
||||
args: { owner: string; ownerType: GitHubProjectOwnerType; projectNumber: number }
|
||||
args: { owner: string; ownerType: GitHubProjectOwnerType; projectNumber: number },
|
||||
repoTarget: GitHubRepoTarget
|
||||
): Promise<ListProjectViewsResult> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
const payload = { ...args, ...repoTarget }
|
||||
return target.kind === 'environment'
|
||||
? callRuntimeRpc<ListProjectViewsResult>(target, 'github.project.listViews', args, {
|
||||
? callRuntimeRpc<ListProjectViewsResult>(target, 'github.project.listViews', payload, {
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
: window.api.gh.listProjectViews(args)
|
||||
: window.api.gh.listProjectViews(payload)
|
||||
}
|
||||
|
||||
async function resolveProjectRefForRuntime(
|
||||
settings: Parameters<typeof getActiveRuntimeTarget>[0],
|
||||
input: string
|
||||
input: string,
|
||||
repoTarget: GitHubRepoTarget
|
||||
): Promise<ResolveProjectRefResult> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
const payload = { input, ...repoTarget }
|
||||
return target.kind === 'environment'
|
||||
? callRuntimeRpc<ResolveProjectRefResult>(
|
||||
target,
|
||||
'github.project.resolveRef',
|
||||
{ input },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: window.api.gh.resolveProjectRef({ input })
|
||||
? callRuntimeRpc<ResolveProjectRefResult>(target, 'github.project.resolveRef', payload, {
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
: window.api.gh.resolveProjectRef(payload)
|
||||
}
|
||||
|
||||
export default function ProjectPicker({ activeProject, onSelect }: Props): React.JSX.Element {
|
||||
export default function ProjectPicker({
|
||||
activeProject,
|
||||
onSelect,
|
||||
repoTarget
|
||||
}: Props): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const mountedRef = useMountedRef()
|
||||
// Why (issue #1715): stable wire payload for gh routing. Memoized on the
|
||||
// primitive fields so a new object identity on every render doesn't bust
|
||||
// useCallback caches (loadBrowse, handleChooseProject, handlePaste).
|
||||
const ghRepoTarget = useMemo<GitHubRepoTarget>(
|
||||
() => ({
|
||||
...(repoTarget?.repoPath ? { repoPath: repoTarget.repoPath } : {}),
|
||||
...(repoTarget?.connectionId !== undefined ? { connectionId: repoTarget.connectionId } : {})
|
||||
}),
|
||||
[repoTarget?.repoPath, repoTarget?.connectionId]
|
||||
)
|
||||
const ghRepoTargetKey = useMemo(() => repoTargetCacheKey(ghRepoTarget), [ghRepoTarget])
|
||||
const projectSettings: GitHubProjectSettings = useMemo(
|
||||
() =>
|
||||
settings?.githubProjects ?? {
|
||||
@@ -125,7 +156,8 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
|
||||
const [query, setQuery] = useState('')
|
||||
const [browseLoading, setBrowseLoading] = useState(false)
|
||||
const [browseError, setBrowseError] = useState<GitHubProjectViewError | null>(null)
|
||||
const browseCache = browseCacheByRuntimeScope.get(getProjectPickerRuntimeScope(settings))
|
||||
const browseCacheKey = `${getProjectPickerRuntimeScope(settings)}:${ghRepoTargetKey}`
|
||||
const browseCache = browseCacheByRuntimeScope.get(browseCacheKey)
|
||||
const [browseProjects, setBrowseProjects] = useState<GitHubProjectSummary[]>(
|
||||
() => browseCache?.projects ?? []
|
||||
)
|
||||
@@ -146,7 +178,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
|
||||
const [viewLoading, setViewLoading] = useState(false)
|
||||
|
||||
const loadBrowse = useCallback(async () => {
|
||||
const cacheKey = getProjectPickerRuntimeScope(settings)
|
||||
const cacheKey = `${getProjectPickerRuntimeScope(settings)}:${ghRepoTargetKey}`
|
||||
const cached = browseCacheByRuntimeScope.get(cacheKey) ?? null
|
||||
if (cached && Date.now() - cached.fetchedAt < BROWSE_CACHE_TTL_MS) {
|
||||
setBrowseProjects(cached.projects)
|
||||
@@ -156,7 +188,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
|
||||
setBrowseLoading(true)
|
||||
setBrowseError(null)
|
||||
try {
|
||||
const res = await listAccessibleProjectsForRuntime(settings)
|
||||
const res = await listAccessibleProjectsForRuntime(settings, ghRepoTarget)
|
||||
if (res.ok) {
|
||||
browseCacheByRuntimeScope.set(cacheKey, {
|
||||
fetchedAt: Date.now(),
|
||||
@@ -186,7 +218,21 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
|
||||
setBrowseLoading(false)
|
||||
}
|
||||
}
|
||||
}, [mountedRef, settings])
|
||||
}, [mountedRef, settings, ghRepoTarget, ghRepoTargetKey])
|
||||
|
||||
// Why (issue #1715): when the repo-target routing key changes (repo switch),
|
||||
// rehydrate from the matching cache entry or clear so stale projects from
|
||||
// the previous host don't linger.
|
||||
useEffect(() => {
|
||||
const cached = browseCacheByRuntimeScope.get(browseCacheKey)
|
||||
if (cached) {
|
||||
setBrowseProjects(cached.projects)
|
||||
setPartialFailures(cached.partialFailures ?? [])
|
||||
return
|
||||
}
|
||||
setBrowseProjects([])
|
||||
setPartialFailures([])
|
||||
}, [browseCacheKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !viewPickFor) {
|
||||
@@ -282,11 +328,15 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
|
||||
})
|
||||
setViewLoading(true)
|
||||
try {
|
||||
const res = await listProjectViewsForRuntime(settings, {
|
||||
owner: selection.owner,
|
||||
ownerType: selection.ownerType,
|
||||
projectNumber: selection.number
|
||||
})
|
||||
const res = await listProjectViewsForRuntime(
|
||||
settings,
|
||||
{
|
||||
owner: selection.owner,
|
||||
ownerType: selection.ownerType,
|
||||
projectNumber: selection.number
|
||||
},
|
||||
ghRepoTarget
|
||||
)
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
@@ -336,7 +386,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
|
||||
}
|
||||
}
|
||||
},
|
||||
[commitSelection, mountedRef, projectSettings.lastViewByProject, settings]
|
||||
[commitSelection, mountedRef, projectSettings.lastViewByProject, settings, ghRepoTarget]
|
||||
)
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
@@ -353,7 +403,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
|
||||
setPasteError(null)
|
||||
setPasteBusy(true)
|
||||
try {
|
||||
const res = await resolveProjectRefForRuntime(settings, input)
|
||||
const res = await resolveProjectRefForRuntime(settings, input, ghRepoTarget)
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
@@ -376,7 +426,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
|
||||
setPasteBusy(false)
|
||||
}
|
||||
}
|
||||
}, [handleChooseProject, mountedRef, pasteInput, settings])
|
||||
}, [handleChooseProject, mountedRef, pasteInput, settings, ghRepoTarget])
|
||||
|
||||
const canSubmitPasteInput = !pasteBusy && hasBoundedGitHubProjectRefInputText(pasteInput)
|
||||
|
||||
@@ -822,7 +872,10 @@ function parseProjectInput(
|
||||
}
|
||||
try {
|
||||
const url = new URL(input)
|
||||
if (url.hostname !== 'github.com') {
|
||||
// Why (issue #1715): accept any host so GHES project URLs parse. The host
|
||||
// itself is re-parsed by the main process resolver for explicit host
|
||||
// routing. Require at least one dot to reject obvious garbage hosts.
|
||||
if (!url.hostname.includes('.')) {
|
||||
return null
|
||||
}
|
||||
const parts = url.pathname.split('/').filter(Boolean)
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
GitHubProjectTable,
|
||||
GitHubProjectViewError,
|
||||
GitHubProjectViewSummary,
|
||||
GitHubRepoTarget,
|
||||
ListProjectViewsResult
|
||||
} from '../../../../shared/github-project-types'
|
||||
import type { GitHubWorkItem } from '../../../../shared/types'
|
||||
@@ -71,14 +72,18 @@ const ORCA_FEATURE_REQUEST_URL = 'https://github.com/stablyai/orca/issues/new'
|
||||
|
||||
function listProjectViewsForRuntime(
|
||||
settings: Parameters<typeof getActiveRuntimeTarget>[0],
|
||||
args: { owner: string; ownerType: 'organization' | 'user'; projectNumber: number }
|
||||
args: { owner: string; ownerType: 'organization' | 'user'; projectNumber: number },
|
||||
repoTarget: GitHubRepoTarget
|
||||
): Promise<ListProjectViewsResult> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
// Why (issue #1715): merge the repo-routing hint into the IPC/RPC payload
|
||||
// so listProjectViews lands on the host that owns the active repo.
|
||||
const payload = { ...repoTarget, ...args }
|
||||
return target.kind === 'environment'
|
||||
? callRuntimeRpc<ListProjectViewsResult>(target, 'github.project.listViews', args, {
|
||||
? callRuntimeRpc<ListProjectViewsResult>(target, 'github.project.listViews', payload, {
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
: window.api.gh.listProjectViews(args)
|
||||
: window.api.gh.listProjectViews(payload)
|
||||
}
|
||||
|
||||
function getProjectViewSourceScope(settings: Parameters<typeof getActiveRuntimeTarget>[0]): string {
|
||||
@@ -96,11 +101,26 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS
|
||||
const patchProjectRowIssueType = useAppStore((s) => s.patchProjectRowIssueType)
|
||||
const addRepoFromStore = useAppStore((s) => s.addRepo)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const activeRepoId = useAppStore((s) => s.activeRepoId)
|
||||
const { lookupSlug, ready: slugIndexReady } = useRepoSlugIndex()
|
||||
const mountedRef = useMountedRef()
|
||||
|
||||
const activeProject = settings?.githubProjects?.activeProject ?? null
|
||||
const projectViewSourceScope = useMemo(() => getProjectViewSourceScope(settings), [settings])
|
||||
// Why (issue #1715): pass the active repo as a gh-host hint to all
|
||||
// project-related gh calls (picker, project view fetch, mutations). gh
|
||||
// resolves the API host from that repo's git remote; without this, GHES
|
||||
// users hit github.com and see misleading scope errors.
|
||||
const activeRepoTarget = useMemo<GitHubRepoTarget>(() => {
|
||||
const repo = activeRepoId ? repos.find((r) => r.id === activeRepoId) : null
|
||||
if (!repo) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
repoPath: repo.path,
|
||||
connectionId: repo.connectionId ?? null
|
||||
}
|
||||
}, [activeRepoId, repos])
|
||||
const lastViewByProject = useMemo(
|
||||
() => settings?.githubProjects?.lastViewByProject ?? {},
|
||||
[settings?.githubProjects?.lastViewByProject]
|
||||
@@ -192,7 +212,8 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS
|
||||
activeProject.number,
|
||||
viewId,
|
||||
queryOverride,
|
||||
projectViewSourceScope
|
||||
projectViewSourceScope,
|
||||
activeRepoTarget
|
||||
)
|
||||
if (projectViewCache[cacheKey]?.data) {
|
||||
return
|
||||
@@ -213,7 +234,8 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS
|
||||
projectViewCache,
|
||||
doFetch,
|
||||
appliedQueryByView,
|
||||
projectViewSourceScope
|
||||
projectViewSourceScope,
|
||||
activeRepoTarget
|
||||
])
|
||||
|
||||
// Load the project's view list whenever the active project changes so the
|
||||
@@ -228,11 +250,15 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void listProjectViewsForRuntime(settings, {
|
||||
owner: activeProject.owner,
|
||||
ownerType: activeProject.ownerType,
|
||||
projectNumber: activeProject.number
|
||||
})
|
||||
void listProjectViewsForRuntime(
|
||||
settings,
|
||||
{
|
||||
owner: activeProject.owner,
|
||||
ownerType: activeProject.ownerType,
|
||||
projectNumber: activeProject.number
|
||||
},
|
||||
activeRepoTarget
|
||||
)
|
||||
.then((res) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
@@ -254,7 +280,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeProject, viewListByProject, settings, projectViewSourceScope])
|
||||
}, [activeProject, viewListByProject, settings, projectViewSourceScope, activeRepoTarget])
|
||||
|
||||
const handleSwitchView = useCallback(
|
||||
async (viewId: string) => {
|
||||
@@ -328,9 +354,16 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS
|
||||
activeProject.number,
|
||||
viewId,
|
||||
currentAppliedOverride,
|
||||
projectViewSourceScope
|
||||
projectViewSourceScope,
|
||||
activeRepoTarget
|
||||
)
|
||||
}, [activeProject, lastViewByProject, currentAppliedOverride, projectViewSourceScope])
|
||||
}, [
|
||||
activeProject,
|
||||
lastViewByProject,
|
||||
currentAppliedOverride,
|
||||
projectViewSourceScope,
|
||||
activeRepoTarget
|
||||
])
|
||||
|
||||
const table: GitHubProjectTable | null = currentCacheKey
|
||||
? (projectViewCache[currentCacheKey]?.data ?? null)
|
||||
@@ -764,6 +797,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS
|
||||
: null
|
||||
}
|
||||
onSelect={handleSelect}
|
||||
repoTarget={activeRepoTarget}
|
||||
/>
|
||||
{currentProjectViewKey ? (
|
||||
// Why: render the search input whenever a view is selected — even
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useMemo, useState } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
|
||||
import { useAppStore } from '@/store'
|
||||
import { activeGitHubRepoTargetFromState } from '@/lib/github-active-repo-target'
|
||||
import type { GlobalSettings } from '../../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
@@ -21,6 +23,16 @@ export function AssigneesEditor({
|
||||
onChange: (add: string[], remove: string[]) => void | Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
// Why (issue #1715): route the assignee lookup to the host that owns the
|
||||
// active repo so GHES projects don't fall through to gh's global host.
|
||||
// Memoized on the primitive id so a fresh target object per render doesn't
|
||||
// refire the metadata IPC.
|
||||
const activeRepoId = useAppStore((s) => s.activeRepoId)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const repoTarget = useMemo(
|
||||
() => activeGitHubRepoTargetFromState({ activeRepoId, repos }),
|
||||
[activeRepoId, repos]
|
||||
)
|
||||
// Why: stabilize the assignee seed identity. `selected` is a fresh array on
|
||||
// every parent render — depending on it directly would refire the IPC for
|
||||
// every unrelated re-render while the popover is open.
|
||||
@@ -29,7 +41,8 @@ export function AssigneesEditor({
|
||||
open ? owner : null,
|
||||
open ? repo : null,
|
||||
seedKey ? seedKey.split(',') : [],
|
||||
sourceSettings
|
||||
sourceSettings,
|
||||
repoTarget
|
||||
)
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(o) => !disabled && setOpen(o)}>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useRepoSlugIndex } from '@/lib/repo-slug-index'
|
||||
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
|
||||
import type { GlobalSettings, PRComment } from '../../../../../shared/types'
|
||||
import type {
|
||||
GitHubRepoTarget,
|
||||
GitHubProjectCommentMutationResult,
|
||||
GitHubProjectMutationResult
|
||||
} from '../../../../../shared/github-project-types'
|
||||
@@ -37,6 +38,21 @@ function useRuntimeSettingsForSlug(owner: string, repo: string) {
|
||||
)
|
||||
}
|
||||
|
||||
// Why (issue #1715): the comment mutations also need the repo-host routing
|
||||
// hint so they land on the host that owns the active repo.
|
||||
function useActiveRepoTarget(): GitHubRepoTarget {
|
||||
const activeRepo = useAppStore((s) =>
|
||||
s.activeRepoId ? (s.repos.find((repo) => repo.id === s.activeRepoId) ?? null) : null
|
||||
)
|
||||
return useMemo(
|
||||
() =>
|
||||
activeRepo
|
||||
? { repoPath: activeRepo.path, connectionId: activeRepo.connectionId ?? null }
|
||||
: {},
|
||||
[activeRepo]
|
||||
)
|
||||
}
|
||||
|
||||
export function CommentsList({
|
||||
owner,
|
||||
repo,
|
||||
@@ -52,6 +68,7 @@ export function CommentsList({
|
||||
}): React.JSX.Element {
|
||||
const fallbackRuntimeSettings = useRuntimeSettingsForSlug(owner, repo)
|
||||
const runtimeSettings = sourceSettings ?? fallbackRuntimeSettings
|
||||
const repoTarget = useActiveRepoTarget()
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{comments.length === 0 ? (
|
||||
@@ -71,6 +88,7 @@ export function CommentsList({
|
||||
onDelete={async () => {
|
||||
const target = getRuntimeTarget(runtimeSettings)
|
||||
const args = {
|
||||
...repoTarget,
|
||||
owner,
|
||||
repo,
|
||||
commentId: c.id
|
||||
@@ -92,6 +110,7 @@ export function CommentsList({
|
||||
onEdit={async (next) => {
|
||||
const target = getRuntimeTarget(runtimeSettings)
|
||||
const args = {
|
||||
...repoTarget,
|
||||
owner,
|
||||
repo,
|
||||
commentId: c.id,
|
||||
@@ -201,6 +220,7 @@ export function NewCommentForm({
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const fallbackRuntimeSettings = useRuntimeSettingsForSlug(owner, repo)
|
||||
const runtimeSettings = sourceSettings ?? fallbackRuntimeSettings
|
||||
const repoTarget = useActiveRepoTarget()
|
||||
const canSubmitComment = hasBoundedCommentBodyText(draft)
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -234,7 +254,7 @@ export function NewCommentForm({
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const target = getRuntimeTarget(runtimeSettings)
|
||||
const args = { owner, repo, number, body: bodyState.body }
|
||||
const args = { ...repoTarget, owner, repo, number, body: bodyState.body }
|
||||
const res = target
|
||||
? await callRuntimeRpc<GitHubProjectCommentMutationResult>(
|
||||
target,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata'
|
||||
import { useAppStore } from '@/store'
|
||||
import { activeGitHubRepoTargetFromState } from '@/lib/github-active-repo-target'
|
||||
import type { GlobalSettings } from '../../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
@@ -21,7 +23,21 @@ export function LabelsEditor({
|
||||
onChange: (add: string[], remove: string[]) => void | Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, sourceSettings)
|
||||
// Why (issue #1715): route the label lookup to the host that owns the active
|
||||
// repo. Memoized on the primitive id so a fresh target object per render
|
||||
// doesn't refire the metadata IPC.
|
||||
const activeRepoId = useAppStore((s) => s.activeRepoId)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const repoTarget = useMemo(
|
||||
() => activeGitHubRepoTargetFromState({ activeRepoId, repos }),
|
||||
[activeRepoId, repos]
|
||||
)
|
||||
const metadata = useRepoLabelsBySlug(
|
||||
open ? owner : null,
|
||||
open ? repo : null,
|
||||
sourceSettings,
|
||||
repoTarget
|
||||
)
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(o) => !disabled && setOpen(o)}>
|
||||
<PopoverTrigger asChild>
|
||||
|
||||
@@ -27,6 +27,16 @@ export function SlugDialogBody({
|
||||
const { owner, repo, number, type, cacheKey } = projectOrigin
|
||||
const patchProjectIssueOrPr = useAppStore((s) => s.patchProjectIssueOrPr)
|
||||
const projectViewCache = useAppStore((s) => s.projectViewCache)
|
||||
const activeRepo = useAppStore((s) =>
|
||||
s.activeRepoId ? (s.repos.find((candidate) => candidate.id === s.activeRepoId) ?? null) : null
|
||||
)
|
||||
const repoTarget = useMemo(
|
||||
() =>
|
||||
activeRepo
|
||||
? { repoPath: activeRepo.path, connectionId: activeRepo.connectionId ?? null }
|
||||
: {},
|
||||
[activeRepo]
|
||||
)
|
||||
|
||||
// Why: the Project row is the source of truth for the list-side columns;
|
||||
// reading it reactively here keeps the dialog in sync with optimistic
|
||||
@@ -57,6 +67,9 @@ export function SlugDialogBody({
|
||||
setError(null)
|
||||
setDetails(null)
|
||||
const target = getActiveRuntimeTarget(sourceSettings)
|
||||
// Why (issue #1715): thread the repo-host routing hint through both the
|
||||
// runtime RPC and the local IPC so the details lookup lands on the host
|
||||
// that owns the active repo (github.com vs GHES).
|
||||
const request =
|
||||
target.kind === 'environment'
|
||||
? callRuntimeRpc<
|
||||
@@ -64,10 +77,10 @@ export function SlugDialogBody({
|
||||
>(
|
||||
target,
|
||||
'github.project.workItemDetailsBySlug',
|
||||
{ owner, repo, number, type },
|
||||
{ ...repoTarget, owner, repo, number, type },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: window.api.gh.projectWorkItemDetailsBySlug({ owner, repo, number, type })
|
||||
: window.api.gh.projectWorkItemDetailsBySlug({ ...repoTarget, owner, repo, number, type })
|
||||
request
|
||||
.then((res) => {
|
||||
if (rid !== requestIdRef.current) {
|
||||
@@ -91,7 +104,7 @@ export function SlugDialogBody({
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
}, [owner, repo, number, type, sourceSettings])
|
||||
}, [owner, repo, number, type, sourceSettings, repoTarget])
|
||||
|
||||
const title = row?.content.title ?? details?.item.title ?? ''
|
||||
const url = row?.content.url ?? details?.item.url ?? null
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import type { GitHubAssignableUser, GlobalSettings } from '../../../shared/types'
|
||||
import type {
|
||||
GitHubRepoTarget,
|
||||
ListAssignableUsersBySlugResult,
|
||||
ListLabelsBySlugResult
|
||||
} from '../../../shared/github-project-types'
|
||||
@@ -33,10 +34,15 @@ export function clearGitHubSlugMetadataCache(): void {
|
||||
clearMetadataRequestStore(slugAssigneeStore)
|
||||
}
|
||||
|
||||
function repoTargetKey(repoTarget?: GitHubRepoTarget): string {
|
||||
return `${repoTarget?.connectionId ?? 'local'}:${repoTarget?.repoPath ?? ''}`
|
||||
}
|
||||
|
||||
export function useRepoLabelsBySlug(
|
||||
owner: string | null,
|
||||
repo: string | null,
|
||||
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
|
||||
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null,
|
||||
repoTarget?: GitHubRepoTarget
|
||||
): MetadataState<string[]> {
|
||||
const [state, setState] = useState<MetadataState<string[]>>({
|
||||
data: [],
|
||||
@@ -50,10 +56,11 @@ export function useRepoLabelsBySlug(
|
||||
return
|
||||
}
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
const routeKey = repoTargetKey(repoTarget)
|
||||
const key =
|
||||
target.kind === 'environment'
|
||||
? `runtime:${target.environmentId}:${owner}/${repo}`
|
||||
: `${owner}/${repo}`
|
||||
? `runtime:${target.environmentId}:${routeKey}:${owner}/${repo}`
|
||||
: `${routeKey}:${owner}/${repo}`
|
||||
|
||||
const cached = getFreshMetadata(slugLabelStore, key)
|
||||
if (cached) {
|
||||
@@ -82,10 +89,10 @@ export function useRepoLabelsBySlug(
|
||||
? callRuntimeRpc<ListLabelsBySlugResult>(
|
||||
target,
|
||||
'github.project.listLabelsBySlug',
|
||||
{ owner, repo },
|
||||
{ ...repoTarget, owner, repo },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: window.api.gh.listLabelsBySlug({ owner, repo })
|
||||
: window.api.gh.listLabelsBySlug({ ...repoTarget, owner, repo })
|
||||
).then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error.message)
|
||||
@@ -110,7 +117,7 @@ export function useRepoLabelsBySlug(
|
||||
error: err instanceof Error ? err.message : 'Failed to load labels'
|
||||
}))
|
||||
})
|
||||
}, [owner, repo, settings])
|
||||
}, [owner, repo, settings, repoTarget])
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -119,7 +126,8 @@ export function useRepoAssigneesBySlug(
|
||||
owner: string | null,
|
||||
repo: string | null,
|
||||
seedLogins?: string[],
|
||||
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
|
||||
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null,
|
||||
repoTarget?: GitHubRepoTarget
|
||||
): MetadataState<GitHubAssignableUser[]> {
|
||||
const [state, setState] = useState<MetadataState<GitHubAssignableUser[]>>({
|
||||
data: [],
|
||||
@@ -137,10 +145,11 @@ export function useRepoAssigneesBySlug(
|
||||
return
|
||||
}
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
const routeKey = repoTargetKey(repoTarget)
|
||||
const key =
|
||||
target.kind === 'environment'
|
||||
? `runtime:${target.environmentId}:${owner}/${repo}#${seedKey}`
|
||||
: `${owner}/${repo}#${seedKey}`
|
||||
? `runtime:${target.environmentId}:${routeKey}:${owner}/${repo}#${seedKey}`
|
||||
: `${routeKey}:${owner}/${repo}#${seedKey}`
|
||||
|
||||
const cached = getFreshMetadata(slugAssigneeStore, key)
|
||||
if (cached) {
|
||||
@@ -165,6 +174,7 @@ export function useRepoAssigneesBySlug(
|
||||
error: null
|
||||
}))
|
||||
const args = {
|
||||
...repoTarget,
|
||||
owner,
|
||||
repo,
|
||||
...(seedKey ? { seedLogins: seedKey.split(',') } : {})
|
||||
@@ -202,7 +212,7 @@ export function useRepoAssigneesBySlug(
|
||||
error: err instanceof Error ? err.message : 'Failed to load assignees'
|
||||
}))
|
||||
})
|
||||
}, [owner, repo, seedKey, settings])
|
||||
}, [owner, repo, seedKey, settings, repoTarget])
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { GitHubRepoTarget } from '../../../shared/github-project-types'
|
||||
|
||||
export function activeGitHubRepoTargetFromState(
|
||||
state: Pick<AppState, 'activeRepoId' | 'repos'>
|
||||
): GitHubRepoTarget {
|
||||
const repo = state.activeRepoId ? state.repos.find((r) => r.id === state.activeRepoId) : null
|
||||
if (!repo) {
|
||||
return {}
|
||||
}
|
||||
return { repoPath: repo.path, connectionId: repo.connectionId ?? null }
|
||||
}
|
||||
@@ -30,7 +30,8 @@ import type {
|
||||
GitHubProjectMutationResult,
|
||||
GitHubProjectRow,
|
||||
GitHubProjectTable,
|
||||
GitHubProjectViewError
|
||||
GitHubProjectViewError,
|
||||
GitHubRepoTarget
|
||||
} from '../../../../shared/github-project-types'
|
||||
import {
|
||||
isGitHubWorkItemsSshRemoteRequiredError,
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
getTaskSourceRuntimeSettings,
|
||||
type TaskSourceContext
|
||||
} from '../../../../shared/task-source-context'
|
||||
import { activeGitHubRepoTargetFromState } from '../../lib/github-active-repo-target'
|
||||
|
||||
// ─── ProjectV2 cache types ────────────────────────────────────────────
|
||||
// Why: declared separately from CacheEntry<T> (not a generified E parameter)
|
||||
@@ -422,9 +424,14 @@ export function projectViewCacheKey(
|
||||
projectNumber: number,
|
||||
resolvedViewId: string,
|
||||
queryOverride?: string,
|
||||
sourceScope = 'local'
|
||||
sourceScope = 'local',
|
||||
// Why (issue #1715): the repo-host routing hint participates in the cache
|
||||
// key so the same owner/project on github.com vs GHES can't reuse each
|
||||
// other's cached table.
|
||||
repoTarget?: GitHubRepoTarget
|
||||
): string {
|
||||
return `github-project:${sourceScope}:${ownerType}:${owner}:${projectNumber}:${resolvedViewId}${queryOverrideKeyPart(queryOverride)}`
|
||||
const targetKey = `${repoTarget?.connectionId ?? 'local'}:${repoTarget?.repoPath ?? ''}`
|
||||
return `github-project:${sourceScope}:${targetKey}:${ownerType}:${owner}:${projectNumber}:${resolvedViewId}${queryOverrideKeyPart(queryOverride)}`
|
||||
}
|
||||
|
||||
function projectViewRequestKey(args: GetProjectViewTableArgs, sourceScope: string): string {
|
||||
@@ -438,7 +445,8 @@ function projectViewRequestKey(args: GetProjectViewTableArgs, sourceScope: strin
|
||||
: args.viewName
|
||||
? `name:${args.viewName}`
|
||||
: 'default'
|
||||
return `${sourceScope}:${args.ownerType}:${args.owner}:${args.projectNumber}:${selector}${queryOverrideKeyPart(args.queryOverride)}`
|
||||
const targetKey = `${args.connectionId ?? 'local'}:${args.repoPath ?? ''}`
|
||||
return `${sourceScope}:${targetKey}:${args.ownerType}:${args.owner}:${args.projectNumber}:${selector}${queryOverrideKeyPart(args.queryOverride)}`
|
||||
}
|
||||
|
||||
function projectViewSourceScope(settings: AppState['settings']): string {
|
||||
@@ -2023,18 +2031,23 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
fetchProjectViewTable: async (args, options) => {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const sourceScope = projectViewSourceScope(get().settings)
|
||||
const requestKey = projectViewRequestKey(args, sourceScope)
|
||||
// Why (issue #1715): include the repo-host routing hint in both the
|
||||
// request and cache identity so same owner/project numbers on different
|
||||
// GitHub hosts cannot reuse each other's data.
|
||||
const payload: GetProjectViewTableArgs = { ...activeGitHubRepoTargetFromState(get()), ...args }
|
||||
const requestKey = projectViewRequestKey(payload, sourceScope)
|
||||
|
||||
// Fast path: when the caller supplies `viewId`, we already know the
|
||||
// resolved cache key and can serve a fresh entry directly.
|
||||
const maybeKnownKey = args.viewId
|
||||
const maybeKnownKey = payload.viewId
|
||||
? projectViewCacheKey(
|
||||
args.ownerType,
|
||||
args.owner,
|
||||
args.projectNumber,
|
||||
args.viewId,
|
||||
args.queryOverride,
|
||||
sourceScope
|
||||
payload.ownerType,
|
||||
payload.owner,
|
||||
payload.projectNumber,
|
||||
payload.viewId,
|
||||
payload.queryOverride,
|
||||
sourceScope,
|
||||
payload
|
||||
)
|
||||
: null
|
||||
if (!options?.force && maybeKnownKey) {
|
||||
@@ -2064,10 +2077,10 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
? await callRuntimeRpc<GetProjectViewTableResult>(
|
||||
target,
|
||||
'github.project.viewTable',
|
||||
args,
|
||||
payload,
|
||||
{ timeoutMs: 60_000 }
|
||||
)
|
||||
: await window.api.gh.getProjectViewTable(args)
|
||||
: await window.api.gh.getProjectViewTable(payload)
|
||||
if (envelope.ok) {
|
||||
const table = envelope.data
|
||||
const key = projectViewCacheKey(
|
||||
@@ -2075,8 +2088,9 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
table.project.owner,
|
||||
table.project.number,
|
||||
table.selectedView.id,
|
||||
args.queryOverride,
|
||||
sourceScope
|
||||
payload.queryOverride,
|
||||
sourceScope,
|
||||
payload
|
||||
)
|
||||
set((s) => ({
|
||||
projectViewCache: withBoundedCacheEntry(s.projectViewCache, key, {
|
||||
@@ -2159,25 +2173,25 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
applyRowPatch(set, cacheKey, rowId, optimisticRow)
|
||||
|
||||
const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey))
|
||||
// Why (issue #1715): route the ProjectV2 field-update GraphQL to the
|
||||
// host that owns the active repo so GHES projects don't fall through
|
||||
// to gh's globally-active host (github.com) and 404 / mis-auth.
|
||||
const updatePayload = {
|
||||
...activeGitHubRepoTargetFromState(state),
|
||||
projectId: table.project.id,
|
||||
itemId: rowId,
|
||||
fieldId,
|
||||
value
|
||||
}
|
||||
const result =
|
||||
target.kind === 'environment'
|
||||
? await callRuntimeRpc<GitHubProjectMutationResult>(
|
||||
target,
|
||||
'github.project.updateItemField',
|
||||
{
|
||||
projectId: table.project.id,
|
||||
itemId: rowId,
|
||||
fieldId,
|
||||
value
|
||||
},
|
||||
updatePayload,
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: await window.api.gh.updateProjectItemField({
|
||||
projectId: table.project.id,
|
||||
itemId: rowId,
|
||||
fieldId,
|
||||
value
|
||||
})
|
||||
: await window.api.gh.updateProjectItemField(updatePayload)
|
||||
if (!result.ok) {
|
||||
rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow)
|
||||
}
|
||||
@@ -2217,23 +2231,23 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
applyRowPatch(set, cacheKey, rowId, optimisticRow)
|
||||
|
||||
const target = getActiveRuntimeTarget(settingsForProjectViewCacheKey(get().settings, cacheKey))
|
||||
// Why (issue #1715): same host-routing concern as updateProjectFieldValue
|
||||
// — clear-field GraphQL must hit the host that owns the active repo.
|
||||
const clearPayload = {
|
||||
...activeGitHubRepoTargetFromState(state),
|
||||
projectId: table.project.id,
|
||||
itemId: rowId,
|
||||
fieldId
|
||||
}
|
||||
const result =
|
||||
target.kind === 'environment'
|
||||
? await callRuntimeRpc<GitHubProjectMutationResult>(
|
||||
target,
|
||||
'github.project.clearItemField',
|
||||
{
|
||||
projectId: table.project.id,
|
||||
itemId: rowId,
|
||||
fieldId
|
||||
},
|
||||
clearPayload,
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: await window.api.gh.clearProjectItemField({
|
||||
projectId: table.project.id,
|
||||
itemId: rowId,
|
||||
fieldId
|
||||
})
|
||||
: await window.api.gh.clearProjectItemField(clearPayload)
|
||||
if (!result.ok) {
|
||||
rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow)
|
||||
}
|
||||
@@ -2327,11 +2341,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
settingsForProjectViewCacheKey(get().settings, cacheKey)
|
||||
)
|
||||
)
|
||||
// Why (issue #1715): the repo-host routing hint must also flow to the
|
||||
// slug-addressed mutation so it lands on the host that owns the repo.
|
||||
const repoTarget = activeGitHubRepoTargetFromState(state)
|
||||
if (
|
||||
previousRow.itemType === 'PULL_REQUEST' &&
|
||||
(updates.title !== undefined || updates.body !== undefined)
|
||||
) {
|
||||
const args = {
|
||||
...repoTarget,
|
||||
owner,
|
||||
repo,
|
||||
number,
|
||||
@@ -2363,6 +2381,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
(updates.title !== undefined || updates.body !== undefined)))
|
||||
) {
|
||||
const args = {
|
||||
...repoTarget,
|
||||
owner,
|
||||
repo,
|
||||
number,
|
||||
@@ -2455,7 +2474,11 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
|
||||
settingsForProjectViewCacheKey(get().settings, cacheKey)
|
||||
)
|
||||
)
|
||||
// Why (issue #1715): also thread the repo-host routing hint into the
|
||||
// slug-addressed issue-type mutation.
|
||||
const repoTarget = activeGitHubRepoTargetFromState(state)
|
||||
const args = {
|
||||
...repoTarget,
|
||||
owner,
|
||||
repo,
|
||||
number,
|
||||
|
||||
@@ -327,7 +327,20 @@ export type ListIssueTypesBySlugResult =
|
||||
|
||||
// ─── IPC arg shapes (shared between main, preload, renderer) ──────────
|
||||
|
||||
export type GetProjectViewTableArgs = {
|
||||
/**
|
||||
* Repo target for routing `gh` to the correct host in multi-host setups
|
||||
* (e.g. github.com + GHES). Main resolves the repo's git remote host and
|
||||
* passes it to `gh api --hostname`; local repos also keep `cwd: repoPath`
|
||||
* for repo-context placeholders. When `connectionId` is set the path is read
|
||||
* through the SSH git provider and local cwd is omitted. Both optional —
|
||||
* calls with no repo context accept gh's globally-active host. See issue #1715.
|
||||
*/
|
||||
export type GitHubRepoTarget = {
|
||||
repoPath?: string
|
||||
connectionId?: string | null
|
||||
}
|
||||
|
||||
export type GetProjectViewTableArgs = GitHubRepoTarget & {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
@@ -343,77 +356,77 @@ export type GetProjectViewTableArgs = {
|
||||
queryOverride?: string
|
||||
}
|
||||
|
||||
export type ProjectWorkItemDetailsBySlugArgs = {
|
||||
export type ProjectWorkItemDetailsBySlugArgs = GitHubRepoTarget & {
|
||||
owner: string
|
||||
repo: string
|
||||
number: number
|
||||
type: 'issue' | 'pr'
|
||||
}
|
||||
|
||||
export type UpdateProjectItemFieldArgs = {
|
||||
export type UpdateProjectItemFieldArgs = GitHubRepoTarget & {
|
||||
projectId: string
|
||||
itemId: string
|
||||
fieldId: string
|
||||
value: GitHubProjectFieldMutationValue
|
||||
}
|
||||
|
||||
export type ClearProjectItemFieldArgs = {
|
||||
export type ClearProjectItemFieldArgs = GitHubRepoTarget & {
|
||||
projectId: string
|
||||
itemId: string
|
||||
fieldId: string
|
||||
}
|
||||
|
||||
export type UpdateIssueBySlugArgs = {
|
||||
export type UpdateIssueBySlugArgs = GitHubRepoTarget & {
|
||||
owner: string
|
||||
repo: string
|
||||
number: number
|
||||
updates: GitHubIssueUpdate & { body?: string }
|
||||
}
|
||||
|
||||
export type UpdatePullRequestBySlugArgs = {
|
||||
export type UpdatePullRequestBySlugArgs = GitHubRepoTarget & {
|
||||
owner: string
|
||||
repo: string
|
||||
number: number
|
||||
updates: { title?: string; body?: string; state?: 'open' | 'closed' }
|
||||
}
|
||||
|
||||
export type AddIssueCommentBySlugArgs = {
|
||||
export type AddIssueCommentBySlugArgs = GitHubRepoTarget & {
|
||||
owner: string
|
||||
repo: string
|
||||
number: number
|
||||
body: string
|
||||
}
|
||||
|
||||
export type UpdateIssueCommentBySlugArgs = {
|
||||
export type UpdateIssueCommentBySlugArgs = GitHubRepoTarget & {
|
||||
owner: string
|
||||
repo: string
|
||||
commentId: number
|
||||
body: string
|
||||
}
|
||||
|
||||
export type DeleteIssueCommentBySlugArgs = {
|
||||
export type DeleteIssueCommentBySlugArgs = GitHubRepoTarget & {
|
||||
owner: string
|
||||
repo: string
|
||||
commentId: number
|
||||
}
|
||||
|
||||
export type ListLabelsBySlugArgs = {
|
||||
export type ListLabelsBySlugArgs = GitHubRepoTarget & {
|
||||
owner: string
|
||||
repo: string
|
||||
}
|
||||
|
||||
export type ListAssignableUsersBySlugArgs = {
|
||||
export type ListAssignableUsersBySlugArgs = GitHubRepoTarget & {
|
||||
owner: string
|
||||
repo: string
|
||||
seedLogins?: string[]
|
||||
}
|
||||
|
||||
export type ListIssueTypesBySlugArgs = {
|
||||
export type ListIssueTypesBySlugArgs = GitHubRepoTarget & {
|
||||
owner: string
|
||||
repo: string
|
||||
}
|
||||
|
||||
export type UpdateIssueTypeBySlugArgs = {
|
||||
export type UpdateIssueTypeBySlugArgs = GitHubRepoTarget & {
|
||||
owner: string
|
||||
repo: string
|
||||
number: number
|
||||
@@ -421,12 +434,14 @@ export type UpdateIssueTypeBySlugArgs = {
|
||||
issueTypeId: string | null
|
||||
}
|
||||
|
||||
export type ResolveProjectRefArgs = {
|
||||
export type ResolveProjectRefArgs = GitHubRepoTarget & {
|
||||
input: string
|
||||
}
|
||||
|
||||
export type ListProjectViewsArgs = {
|
||||
export type ListProjectViewsArgs = GitHubRepoTarget & {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
}
|
||||
|
||||
export type ListAccessibleProjectsArgs = GitHubRepoTarget
|
||||
|
||||
Reference in New Issue
Block a user