Keep GHES web ports but drop SSH transport ports in host identity

Supersedes PR #9118 on this branch: http(s) remote ports identify the
Enterprise web/API endpoint and are preserved, while ssh/git transport
ports (including ssh.github.com:443) never leak into gh's host identity.
Replaces the ssh.github.com:443 special case with the structural
protocol split and ports the PR's parsing test suite.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-20 12:35:26 -07:00
co-authored by Orca
parent a6160e818b
commit 02206e6a0f
21 changed files with 427 additions and 45 deletions
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import { parseGitHubOwnerRepo, parseGitHubRemoteIdentity } from './github-remote-identity-parsing'
describe('parseGitHubRemoteIdentity', () => {
it('parses a plain github.com https remote', () => {
expect(parseGitHubRemoteIdentity('https://github.com/team/orca.git')).toEqual({
host: 'github.com',
owner: 'team',
repo: 'orca'
})
})
it('parses an SCP-style github.com remote', () => {
expect(parseGitHubRemoteIdentity('git@github.com:team/orca.git')).toEqual({
host: 'github.com',
owner: 'team',
repo: 'orca'
})
})
it('preserves a custom port on a GHES https remote', () => {
// The port IS the Enterprise web/API endpoint, so gh must target
// ghe.acme.com:8443, not the portless hostname.
expect(parseGitHubRemoteIdentity('https://ghe.acme.com:8443/team/orca.git')).toEqual({
host: 'ghe.acme.com:8443',
owner: 'team',
repo: 'orca'
})
})
it('preserves a custom port on a GHES http remote', () => {
expect(parseGitHubRemoteIdentity('http://ghe.acme.com:8080/team/orca.git')).toEqual({
host: 'ghe.acme.com:8080',
owner: 'team',
repo: 'orca'
})
})
it('drops the default https port so github.com stays bare', () => {
// WHATWG URL omits default ports, so :443 never leaks into the host.
expect(parseGitHubRemoteIdentity('https://github.com:443/team/orca.git')?.host).toBe(
'github.com'
)
})
it('drops the default https port on a GHES host', () => {
expect(parseGitHubRemoteIdentity('https://ghe.acme.com:443/team/orca.git')?.host).toBe(
'ghe.acme.com'
)
})
it('normalizes ssh.github.com:443 (SSH-over-HTTPS) to github.com without a port', () => {
// :443 here is the ssh transport port, not an endpoint — it must not survive.
expect(parseGitHubRemoteIdentity('ssh://git@ssh.github.com:443/team/orca.git')).toEqual({
host: 'github.com',
owner: 'team',
repo: 'orca'
})
})
it('drops a custom ssh transport port on a GHES ssh remote', () => {
// ssh://…:2222 is a transport port; gh routes by hostname, so keep only the host.
expect(parseGitHubRemoteIdentity('ssh://git@ghe.acme.com:2222/team/orca.git')?.host).toBe(
'ghe.acme.com'
)
})
it('drops the transport port for git+ssh and git remotes', () => {
expect(parseGitHubRemoteIdentity('git+ssh://git@ghe.acme.com:2222/team/orca.git')?.host).toBe(
'ghe.acme.com'
)
expect(parseGitHubRemoteIdentity('git://ghe.acme.com:9418/team/orca.git')?.host).toBe(
'ghe.acme.com'
)
})
it('lowercases the host while keeping the port', () => {
expect(parseGitHubRemoteIdentity('https://GHE.Acme.COM:8443/team/orca.git')?.host).toBe(
'ghe.acme.com:8443'
)
})
it('returns null for an unparseable remote', () => {
expect(parseGitHubRemoteIdentity('not-a-remote')).toBeNull()
})
})
describe('parseGitHubOwnerRepo', () => {
it('returns owner/repo for github.com', () => {
expect(parseGitHubOwnerRepo('https://github.com/team/orca.git')).toEqual({
owner: 'team',
repo: 'orca'
})
})
it('returns null for a custom-port GHES remote (not github.com)', () => {
// GHES is handled by the enterprise resolver, not the github.com fast path,
// and the port must not make a github.com remote look like GHES either.
expect(parseGitHubOwnerRepo('https://ghe.acme.com:8443/team/orca.git')).toBeNull()
})
it('still recognizes github.com even with an explicit default port', () => {
expect(parseGitHubOwnerRepo('https://github.com:443/team/orca.git')).toEqual({
owner: 'team',
repo: 'orca'
})
})
})
@@ -4,10 +4,15 @@ export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string }
function normalizeGitHubRemoteHost(host: string): string {
const normalizedHost = host.toLowerCase()
// Why: GitHub documents ssh.github.com:443 as SSH-over-HTTPS for github.com repos.
return normalizedHost === 'ssh.github.com' || normalizedHost === 'ssh.github.com:443'
? 'github.com'
: normalizedHost
// Why: GitHub documents ssh.github.com as SSH-over-HTTPS for github.com repos.
return normalizedHost === 'ssh.github.com' ? 'github.com' : normalizedHost
}
// Why: HTTP ports identify the GHES web/API endpoint; SSH and git ports are
// transport-only and must not leak into gh's host identity.
function hostFromRemoteUrl(url: URL): string {
const protocol = url.protocol.toLowerCase()
return protocol === 'http:' || protocol === 'https:' ? url.host : url.hostname
}
function parseGitHubRemotePath(path: string): Pick<GitHubRemoteIdentity, 'owner' | 'repo'> | null {
@@ -36,9 +41,7 @@ export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdenti
return null
}
const path = parseGitHubRemotePath(url.pathname)
// Why: GHES may be served on a non-default port. `hostname` silently drops
// it, sending authenticated API calls to a different endpoint on port 443.
return path ? { host: normalizeGitHubRemoteHost(url.host), ...path } : null
return path ? { host: normalizeGitHubRemoteHost(hostFromRemoteUrl(url)), ...path } : null
} catch {
return null
}
+9
View File
@@ -167,6 +167,15 @@ describe('parseProjectPaste', () => {
).toEqual({ kind: 'org', owner: 'acme', number: 7 })
})
it('preserves a GHES custom port while parsing project URLs', () => {
expect(
parseProjectPaste(
'https://github.corp.example:8443/orgs/acme/projects/7',
'github.corp.example:8443'
)
).toEqual({ kind: 'org', owner: 'acme', number: 7 })
})
it('returns null for empty input', () => {
expect(parseProjectPaste('')).toBeNull()
expect(parseProjectPaste(' ')).toBeNull()
@@ -66,12 +66,12 @@ describe('updateIssueBySlug', () => {
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
['issue', 'close', '12', '--repo', 'acme/widgets', '--reason', 'completed'],
{ encoding: 'utf-8' }
{ encoding: 'utf-8', host: 'github.com' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['issue', 'close', '13', '--repo', 'acme/widgets', '--reason', 'not planned'],
{ encoding: 'utf-8' }
{ encoding: 'utf-8', host: 'github.com' }
)
expect(runRestMock).not.toHaveBeenCalled()
})
@@ -88,7 +88,7 @@ describe('updateIssueBySlug', () => {
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['issue', 'close', '12', '--repo', 'acme/widgets', '--duplicate-of', '99'],
{ encoding: 'utf-8' }
{ encoding: 'utf-8', host: 'github.com' }
)
expect(runRestMock).not.toHaveBeenCalled()
})
@@ -121,7 +121,7 @@ describe('updateIssueBySlug', () => {
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['issue', 'reopen', '12', '--repo', 'acme/widgets'],
{ encoding: 'utf-8' }
{ encoding: 'utf-8', host: 'github.com' }
)
})
+5 -1
View File
@@ -16,7 +16,7 @@ import {
type GraphqlVars
} from './internals'
import { classifyProjectError, rateLimitedError } from './project-error-classification'
import { githubHostExecOptions } from '../github-api-repository'
import { githubProjectHost } from '../../../shared/github-project-identity'
import type { GitHubAssignableUser, GitHubWorkItemDetails, PRComment } from '../../../shared/types'
import type {
AddIssueCommentBySlugArgs,
@@ -40,6 +40,10 @@ import type {
UpdateProjectItemFieldArgs
} from '../../../shared/github-project-types'
function githubHostExecOptions(args: { host?: string }): { host: string } {
return { host: githubProjectHost(args.host) }
}
// ─── Project field mutations ──────────────────────────────────────────
class UnknownFieldMutationKindError extends Error {
+14 -2
View File
@@ -32,7 +32,14 @@ describe('client UI RPC methods', () => {
minimaxGroupId: 'group-42',
minimaxUsageModels: 'general,abab6.5',
githubProjects: {
pinned: [],
pinned: [
{
owner: 'stablyai',
ownerType: 'organization' as const,
number: 1,
host: 'ghe.example:8443'
}
],
recent: [],
lastViewByProject: {},
activeProject: null
@@ -68,7 +75,12 @@ describe('client UI RPC methods', () => {
lastViewByProject: {
'organization:stablyai:1': { viewId: 'view-1' }
},
activeProject: { owner: 'stablyai', ownerType: 'organization', number: 1 }
activeProject: {
owner: 'stablyai',
ownerType: 'organization' as const,
number: 1,
host: 'ghe.example:8443'
}
}
}
const runtime = {
@@ -227,18 +227,18 @@ import {
type TaskPageGitHubCloseAction
} from '@/components/task-page-github-status-actions'
// Why: the dialog lacks owner/repo context, so recover it from the canonical URL present on every item; non-GitHub hosts return null (matches the indicator's suppression rule).
// Why: the dialog lacks repository context, so recover its host-aware identity from the canonical item URL.
function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null {
try {
const parsed = new URL(url)
if (parsed.hostname !== 'github.com') {
if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || !parsed.host) {
return null
}
const segments = parsed.pathname.split('/').filter(Boolean)
if (segments.length < 2) {
return null
}
return { owner: segments[0], repo: segments[1] }
return { owner: segments[0], repo: segments[1], host: parsed.host }
} catch {
return null
}
@@ -514,7 +514,8 @@ function PRAssigneesPanel({
slugOwner,
slugRepo,
assigneeLogins,
sourceSettings
sourceSettings,
projectOrigin?.host ?? assigneeSlug?.host
)
const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, sourceSettings)
const repoAssignees = slugOwner && slugRepo ? repoAssigneesBySlug : repoAssigneesByPath
@@ -795,7 +796,8 @@ function PRReviewersPanel({
open && reviewSlug ? reviewSlug.owner : null,
open && reviewSlug ? reviewSlug.repo : null,
reviewerSeedUsers.map((user) => user.login),
sourceSettings
sourceSettings,
reviewSlug?.host
)
const reviewerMetadataByPath = useRepoAssignees(
open && !reviewSlug ? repoPath : null,
@@ -5211,6 +5213,7 @@ async function runIssueUpdate(args: {
const updateArgs = {
owner: args.projectOrigin.owner,
repo: args.projectOrigin.repo,
...(args.projectOrigin.host ? { host: args.projectOrigin.host } : {}),
number: args.number,
updates: args.updates
}
@@ -5291,7 +5294,11 @@ async function runWorkItemBodyUpdate(args: {
}): Promise<void> {
if (args.item.type === 'pr') {
const targetSlug = args.projectOrigin
? { owner: args.projectOrigin.owner, repo: args.projectOrigin.repo }
? {
owner: args.projectOrigin.owner,
repo: args.projectOrigin.repo,
host: args.projectOrigin.host
}
: args.parsedSlug
if (!targetSlug) {
throw new Error('No GitHub repository context available for this pull request.')
@@ -5304,6 +5311,7 @@ async function runWorkItemBodyUpdate(args: {
const updateArgs = {
owner: targetSlug.owner,
repo: targetSlug.repo,
...(targetSlug.host ? { host: targetSlug.host } : {}),
number: args.item.number,
updates: { body: args.body }
}
@@ -5363,6 +5371,7 @@ async function runPullRequestStateUpdate(args: {
const updateArgs = {
owner: args.projectOrigin.owner,
repo: args.projectOrigin.repo,
...(args.projectOrigin.host ? { host: args.projectOrigin.host } : {}),
number: args.number,
updates: args.updates
}
@@ -5570,7 +5579,12 @@ function GHEditSection({
projectOrigin ? null : repoId,
sourceSettings
)
const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo, sourceSettings)
const repoLabelsBySlug = useRepoLabelsBySlug(
slugOwner,
slugRepo,
sourceSettings,
projectOrigin?.host
)
const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath
const repositoryLabelsUrl = useMemo(() => getGitHubRepositoryLabelsUrl(item.url), [item.url])
const repoAssigneesByPath = useRepoAssignees(
@@ -5578,7 +5592,13 @@ function GHEditSection({
projectOrigin ? null : repoId,
sourceSettings
)
const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees, sourceSettings)
const repoAssigneesBySlug = useRepoAssigneesBySlug(
slugOwner,
slugRepo,
assignees,
sourceSettings,
projectOrigin?.host
)
const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath
const hasAttachedWorkspace =
attachedWorkspaceLabel !== null && attachedWorkspaceLabel !== undefined
@@ -43,6 +43,7 @@ type Props = {
onEditLabels?: (add: string[], remove: string[]) => void
onEditIssueType?: (issueType: GitHubIssueType | null) => void
onOpenDialog?: () => void
sourceHost?: string
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
}
@@ -55,6 +56,7 @@ export default function ProjectCell({
onEditLabels,
onEditIssueType,
onOpenDialog,
sourceHost,
sourceSettings
}: Props): React.JSX.Element {
const value = row.fieldValuesByFieldId[field.id]
@@ -70,6 +72,7 @@ export default function ProjectCell({
<TypeCell
row={row}
editable={editableHere}
sourceHost={sourceHost}
sourceSettings={sourceSettings}
onEditIssueType={onEditIssueType}
/>
@@ -81,6 +84,7 @@ export default function ProjectCell({
<AssigneesCell
row={row}
editable={editableHere}
sourceHost={sourceHost}
sourceSettings={sourceSettings}
onEditAssignees={onEditAssignees}
/>
@@ -92,6 +96,7 @@ export default function ProjectCell({
<LabelsCell
row={row}
editable={editableHere}
sourceHost={sourceHost}
sourceSettings={sourceSettings}
onEditLabels={onEditLabels}
/>
@@ -263,11 +268,13 @@ function TitleCell({
function TypeCell({
row,
editable,
sourceHost,
sourceSettings,
onEditIssueType
}: {
row: GitHubProjectRow
editable: boolean
sourceHost?: string
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
onEditIssueType?: (issueType: GitHubIssueType | null) => void
}): React.JSX.Element {
@@ -279,6 +286,7 @@ function TypeCell({
<IssueTypeCell
row={row}
editable={editable}
sourceHost={sourceHost}
sourceSettings={sourceSettings}
onEditIssueType={onEditIssueType}
/>
@@ -311,11 +319,13 @@ function TypeCell({
function IssueTypeCell({
row,
editable,
sourceHost,
sourceSettings,
onEditIssueType
}: {
row: GitHubProjectRow
editable: boolean
sourceHost?: string
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
onEditIssueType?: (issueType: GitHubIssueType | null) => void
}): React.JSX.Element {
@@ -326,8 +336,8 @@ function IssueTypeCell({
const [owner, repo] = (row.content.repository ?? '').split('/')
const { lookupSlug } = useRepoSlugIndex()
const matchedRepo = useMemo(
() => lookupSlug(row.content.repository)[0] ?? null,
[lookupSlug, row.content.repository]
() => lookupSlug(row.content.repository, sourceHost)[0] ?? null,
[lookupSlug, row.content.repository, sourceHost]
)
const ownerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, matchedRepo?.id ?? null))
@@ -345,10 +355,14 @@ function IssueTypeCell({
? callRuntimeRpc<ListIssueTypesBySlugResult>(
target,
'github.project.listIssueTypesBySlug',
{ owner, repo },
{ owner, repo, ...(sourceHost ? { host: sourceHost } : {}) },
{ timeoutMs: 30_000 }
)
: window.api.gh.listIssueTypesBySlug({ owner, repo })
: window.api.gh.listIssueTypesBySlug({
owner,
repo,
...(sourceHost ? { host: sourceHost } : {})
})
request
.then((res) => {
if (cancelled) {
@@ -366,7 +380,7 @@ function IssueTypeCell({
return () => {
cancelled = true
}
}, [matchedRepo, open, owner, ownerSettings, repo, sourceSettings])
}, [matchedRepo, open, owner, ownerSettings, repo, sourceHost, sourceSettings])
const trigger = (
<span className="inline-flex items-center gap-1 text-xs">
@@ -818,11 +832,13 @@ function UserChip({ user }: { user: GitHubProjectUser }): React.JSX.Element {
function AssigneesCell({
row,
editable,
sourceHost,
sourceSettings,
onEditAssignees
}: {
row: GitHubProjectRow
editable: boolean
sourceHost?: string
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
onEditAssignees?: (add: string[], remove: string[]) => void
}): React.JSX.Element {
@@ -849,7 +865,8 @@ function AssigneesCell({
open ? owner : null,
open ? repo : null,
seedKey ? seedKey.split(',') : [],
sourceSettings
sourceSettings,
sourceHost
)
const labelContent =
@@ -933,11 +950,13 @@ function AssigneesCell({
function LabelsCell({
row,
editable,
sourceHost,
sourceSettings,
onEditLabels
}: {
row: GitHubProjectRow
editable: boolean
sourceHost?: string
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
onEditLabels?: (add: string[], remove: string[]) => void
}): React.JSX.Element {
@@ -945,7 +964,12 @@ function LabelsCell({
const [open, setOpen] = useState(false)
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,
sourceHost
)
const labelContent =
labels.length === 0 ? null : labels.map((l) => <LabelChip key={l.name} label={l} />)
@@ -825,7 +825,7 @@ function AuthErrorBanner({ error }: { error: GitHubProjectViewError }): React.JS
)
}
function parseProjectInput(
export function parseProjectInput(
input: string
): { owner: string; number: number; host?: string; viewNumber?: number } | null {
if (!input) {
@@ -34,6 +34,7 @@ type Props = {
onEditIssueType?: (issueType: GitHubIssueType | null) => void
onStartWork?: () => void
onOpenInBrowser?: () => void
sourceHost?: string
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
}
@@ -51,6 +52,7 @@ export default function ProjectRow({
onEditIssueType,
onStartWork,
onOpenInBrowser,
sourceHost,
sourceSettings
}: Props): React.JSX.Element {
const disabled = row.itemType === 'REDACTED'
@@ -100,6 +102,7 @@ export default function ProjectRow({
onEditLabels={onEditLabels}
onEditIssueType={onEditIssueType}
onOpenDialog={f.dataType === 'TITLE' ? onOpenDialog : undefined}
sourceHost={sourceHost}
sourceSettings={sourceSettings}
/>
</div>
@@ -258,6 +258,7 @@ export default function ProjectViewList({
onEditIssueType={(issueType) => onEditIssueType?.(row, issueType)}
onStartWork={() => onStartWork?.(row)}
onOpenInBrowser={() => onOpenInBrowser?.(row)}
sourceHost={table.project.host}
sourceSettings={sourceSettings}
/>
))
@@ -10,10 +10,12 @@ function project(
owner: string,
number: number,
title: string,
ownerType: GitHubProjectSummary['ownerType'] = 'organization'
ownerType: GitHubProjectSummary['ownerType'] = 'organization',
host?: string
): GitHubProjectSummary {
return {
id: `${owner}-${number}`,
...(host ? { host } : {}),
owner,
ownerType,
number,
@@ -73,6 +75,20 @@ describe('github-project-picker-filter', () => {
).toEqual([projects[0]])
})
it('does not hide a GHES project behind a same-named github.com pin', () => {
const dotCom = project('acme', 1, 'Dotcom')
const enterprise = project('acme', 1, 'Enterprise', 'organization', 'ghe.example')
expect(
filterGitHubProjectPickerProjects({
projects: [dotCom, enterprise],
pinned: [{ owner: 'acme', ownerType: 'organization', number: 1 }],
recent: [],
query: ''
})
).toEqual([enterprise])
})
it('enforces the query budget by UTF-8 byte length', () => {
const query = 'é'.repeat(GITHUB_PROJECT_PICKER_QUERY_MAX_BYTES)
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { parseProjectInput } from './ProjectPicker'
describe('ProjectPicker project input', () => {
it('preserves the host and custom port from a GHES project URL', () => {
expect(
parseProjectInput('https://github.corp.example:8443/orgs/acme/projects/7/views/2')
).toEqual({
owner: 'acme',
number: 7,
host: 'github.corp.example:8443',
viewNumber: 2
})
})
it('keeps owner/number shorthand on the default host', () => {
expect(parseProjectInput('acme/7')).toEqual({ owner: 'acme', number: 7 })
})
})
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github-project-types'
import type { Repo } from '../../../../shared/types'
import {
@@ -195,6 +195,21 @@ describe('resolveSelectedProjectRowRepo', () => {
expect(resolution).toMatchObject({ status: 'selected_match', repo: { id: 'repo-2' } })
})
it('passes the project host into repository matching', () => {
const lookupSlug = vi.fn(() => [repo('repo-1')])
expect(
resolveSelectedProjectRowRepo({
row: row('enterprise', 'acme/orca'),
lookupSlug,
host: 'ghe.example:8443',
slugIndexReady: true,
selectedRepoIds: new Set(['repo-1'])
})
).toMatchObject({ status: 'selected_match' })
expect(lookupSlug).toHaveBeenCalledWith('acme/orca', 'ghe.example:8443')
})
it('reports ambiguity when multiple matching repos are selected', () => {
const resolution = resolveSelectedProjectRowRepo({
row: row('ambiguous', 'acme/orca'),
@@ -8,6 +8,7 @@ import { translate } from '@/i18n/i18n'
export function AssigneesEditor({
owner,
repo,
host,
selected,
disabled,
sourceSettings,
@@ -15,6 +16,7 @@ export function AssigneesEditor({
}: {
owner: string
repo: string
host?: string
selected: string[]
disabled?: boolean
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
@@ -29,7 +31,8 @@ export function AssigneesEditor({
open ? owner : null,
open ? repo : null,
seedKey ? seedKey.split(',') : [],
sourceSettings
sourceSettings,
host
)
return (
<Popover open={open} onOpenChange={(o) => !disabled && setOpen(o)}>
@@ -24,11 +24,11 @@ function getRuntimeTarget(settings: Parameters<typeof getActiveRuntimeTarget>[0]
return target.kind === 'environment' ? target : null
}
function useRuntimeSettingsForSlug(owner: string, repo: string) {
function useRuntimeSettingsForSlug(owner: string, repo: string, host?: string) {
const { lookupSlug } = useRepoSlugIndex()
const matchedRepo = useMemo(
() => lookupSlug(`${owner}/${repo}`)[0] ?? null,
[lookupSlug, owner, repo]
() => lookupSlug(`${owner}/${repo}`, host)[0] ?? null,
[lookupSlug, owner, repo, host]
)
return useAppStore(
useShallow((s) =>
@@ -40,17 +40,19 @@ function useRuntimeSettingsForSlug(owner: string, repo: string) {
export function CommentsList({
owner,
repo,
host,
comments,
sourceSettings,
onChange
}: {
owner: string
repo: string
host?: string
comments: PRComment[]
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
onChange: (next: PRComment[]) => void
}): React.JSX.Element {
const fallbackRuntimeSettings = useRuntimeSettingsForSlug(owner, repo)
const fallbackRuntimeSettings = useRuntimeSettingsForSlug(owner, repo, host)
const runtimeSettings = sourceSettings ?? fallbackRuntimeSettings
return (
<div className="flex flex-col gap-3">
@@ -73,6 +75,7 @@ export function CommentsList({
const args = {
owner,
repo,
...(host ? { host } : {}),
commentId: c.id
}
const res = target
@@ -94,6 +97,7 @@ export function CommentsList({
const args = {
owner,
repo,
...(host ? { host } : {}),
commentId: c.id,
body: next
}
@@ -187,19 +191,21 @@ function CommentRow({
export function NewCommentForm({
owner,
repo,
host,
number,
sourceSettings,
onAdded
}: {
owner: string
repo: string
host?: string
number: number
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
onAdded: (c: PRComment) => void
}): React.JSX.Element {
const [draft, setDraft] = useState('')
const [submitting, setSubmitting] = useState(false)
const fallbackRuntimeSettings = useRuntimeSettingsForSlug(owner, repo)
const fallbackRuntimeSettings = useRuntimeSettingsForSlug(owner, repo, host)
const runtimeSettings = sourceSettings ?? fallbackRuntimeSettings
const canSubmitComment = hasBoundedCommentBodyText(draft)
return (
@@ -234,7 +240,13 @@ export function NewCommentForm({
setSubmitting(true)
try {
const target = getRuntimeTarget(runtimeSettings)
const args = { owner, repo, number, body: bodyState.body }
const args = {
owner,
repo,
...(host ? { host } : {}),
number,
body: bodyState.body
}
const res = target
? await callRuntimeRpc<GitHubProjectCommentMutationResult>(
target,
@@ -8,6 +8,7 @@ import { translate } from '@/i18n/i18n'
export function LabelsEditor({
owner,
repo,
host,
selected,
disabled,
sourceSettings,
@@ -15,13 +16,19 @@ export function LabelsEditor({
}: {
owner: string
repo: string
host?: string
selected: string[]
disabled?: boolean
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
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)
const metadata = useRepoLabelsBySlug(
open ? owner : null,
open ? repo : null,
sourceSettings,
host
)
return (
<Popover open={open} onOpenChange={(o) => !disabled && setOpen(o)}>
<PopoverTrigger asChild>
@@ -24,7 +24,7 @@ export function SlugDialogBody({
sourceSettings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
onClose: () => void
}): React.JSX.Element {
const { owner, repo, number, type, cacheKey } = projectOrigin
const { owner, repo, host, number, type, cacheKey } = projectOrigin
const patchProjectIssueOrPr = useAppStore((s) => s.patchProjectIssueOrPr)
const projectViewCache = useAppStore((s) => s.projectViewCache)
@@ -64,10 +64,10 @@ export function SlugDialogBody({
>(
target,
'github.project.workItemDetailsBySlug',
{ owner, repo, number, type },
{ owner, repo, host, number, type },
{ timeoutMs: 30_000 }
)
: window.api.gh.projectWorkItemDetailsBySlug({ owner, repo, number, type })
: window.api.gh.projectWorkItemDetailsBySlug({ owner, repo, host, number, type })
request
.then((res) => {
if (rid !== requestIdRef.current) {
@@ -91,7 +91,7 @@ export function SlugDialogBody({
}
setLoading(false)
})
}, [owner, repo, number, type, sourceSettings])
}, [owner, repo, host, number, type, sourceSettings])
const title = row?.content.title ?? details?.item.title ?? ''
const url = row?.content.url ?? details?.item.url ?? null
@@ -221,6 +221,7 @@ export function SlugDialogBody({
<LabelsEditor
owner={owner}
repo={repo}
host={host}
selected={labels}
disabled={!row}
sourceSettings={sourceSettings}
@@ -242,6 +243,7 @@ export function SlugDialogBody({
<AssigneesEditor
owner={owner}
repo={repo}
host={host}
selected={assignees}
disabled={!row}
sourceSettings={sourceSettings}
@@ -337,6 +339,7 @@ export function SlugDialogBody({
<CommentsList
owner={owner}
repo={repo}
host={host}
comments={details.comments}
sourceSettings={sourceSettings}
onChange={(next) => setDetails((d) => (d ? { ...d, comments: next } : d))}
@@ -344,6 +347,7 @@ export function SlugDialogBody({
<NewCommentForm
owner={owner}
repo={repo}
host={host}
number={number}
sourceSettings={sourceSettings}
onAdded={(c) => setDetails((d) => (d ? { ...d, comments: [...d.comments, c] } : d))}
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it } from 'vitest'
import type { Repo } from '../../../shared/types'
import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key'
import {
lookupReposBySlugFromCache,
settingsForRepoOwner,
slugByRepoId,
slugCacheKey
} from './repo-slug-cache'
function repo(id: string): Repo {
return {
id,
path: `/${id}`,
displayName: id,
badgeColor: '#000000',
addedAt: 1,
executionHostId: 'local'
}
}
describe('repo slug cache host identity', () => {
beforeEach(() => slugByRepoId.clear())
it('does not route a GHES project row to a same-named github.com repo', () => {
const dotCom = repo('dotcom')
const enterprise = repo('enterprise')
for (const [candidate, host] of [
[dotCom, 'github.com'],
[enterprise, 'ghe.example:8443']
] as const) {
slugByRepoId.set(
slugCacheKey(candidate.id, settingsForRepoOwner(candidate, null)),
githubRepoIdentityKey({ owner: 'acme', repo: 'widgets', host })
)
}
expect(lookupReposBySlugFromCache([dotCom, enterprise], null, 'acme/widgets')).toEqual([dotCom])
expect(
lookupReposBySlugFromCache([dotCom, enterprise], null, 'acme/widgets', 'ghe.example:8443')
).toEqual([enterprise])
})
})
+60 -1
View File
@@ -6898,6 +6898,62 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
).toBe('project-local')
})
it('keeps same-named github.com and GHES project cache entries separate', async () => {
const store = createTestStore()
const makeTable = (host: string, id: string) => ({
project: {
id,
host,
owner: 'acme',
ownerType: 'organization' as const,
number: 1,
title: id,
url: `https://${host}/orgs/acme/projects/1`
},
selectedView: {
id: 'view-1',
number: 1,
name: 'Table',
layout: 'TABLE_LAYOUT' as const,
filter: '',
fields: [],
groupByFields: [],
sortByFields: []
},
rows: [],
totalCount: 0,
parentFieldDropped: false
})
mockApi.gh.getProjectViewTable
.mockResolvedValueOnce({ ok: true, data: makeTable('github.com', 'dotcom-project') })
.mockResolvedValueOnce({ ok: true, data: makeTable('ghe.example', 'enterprise-project') })
for (const host of ['github.com', 'ghe.example']) {
await store.getState().fetchProjectViewTable({
owner: 'acme',
ownerType: 'organization',
projectNumber: 1,
viewId: 'view-1',
host
})
}
expect(mockApi.gh.getProjectViewTable).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ host: 'ghe.example' })
)
expect(
store.getState().projectViewCache[
projectViewCacheKey('organization', 'acme', 1, 'view-1', undefined, 'local', 'ghe.example')
]?.data?.project.id
).toBe('enterprise-project')
expect(
store.getState().projectViewCache[
projectViewCacheKey('organization', 'acme', 1, 'view-1', undefined, 'local', 'github.com')
]?.data?.project.id
).toBe('dotcom-project')
})
it('routes project field mutations through the source encoded in the cache key', async () => {
const store = createTestStore()
const cacheKey = projectViewCacheKey(
@@ -6906,7 +6962,8 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
1,
'view-1',
undefined,
'runtime:env-project'
'runtime:env-project',
'ghe.example:8443'
)
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-focused' },
@@ -6916,6 +6973,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
data: {
project: {
id: 'project-1',
host: 'ghe.example:8443',
owner: 'acme',
ownerType: 'organization',
number: 1,
@@ -6975,6 +7033,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
method: 'github.project.updateItemField',
params: {
projectId: 'project-1',
host: 'ghe.example:8443',
itemId: 'row-1',
fieldId: 'field-1',
value: { kind: 'text', text: 'next' }
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { githubProjectHost, githubProjectIdentityKey } from './github-project-identity'
describe('GitHub project identity', () => {
it('keeps legacy github.com keys while isolating Enterprise hosts and ports', () => {
const project = { owner: 'Acme', ownerType: 'organization' as const, number: 7 }
expect(githubProjectIdentityKey(project)).toBe('organization:acme:7')
expect(githubProjectIdentityKey({ ...project, host: 'github.com' })).toBe('organization:acme:7')
expect(githubProjectIdentityKey({ ...project, host: 'GHE.EXAMPLE:8443' })).toBe(
'ghe.example:8443:organization:acme:7'
)
})
it('pins host-less projects to github.com', () => {
expect(githubProjectHost()).toBe('github.com')
expect(githubProjectHost('ghe.example:8443')).toBe('ghe.example:8443')
})
})