mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -25,7 +25,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
|
||||
@@ -61,7 +68,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 = {
|
||||
|
||||
@@ -229,21 +229,20 @@ import {
|
||||
|
||||
// Why: the GH item dialog can be opened from any work-item list surface and
|
||||
// doesn't have the full owner/repo context the list's cache entry carries.
|
||||
// Parsing the canonical `https://github.com/{owner}/{repo}/...` URL is the
|
||||
// Parsing the canonical `https://{github-host}/{owner}/{repo}/...` URL is the
|
||||
// simplest reliable source — the URL is already present on every work item
|
||||
// and survives the main-process → IPC boundary. Non-GitHub hosts return null,
|
||||
// which matches the indicator's suppression rule.
|
||||
// and survives the main-process → IPC boundary.
|
||||
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
|
||||
}
|
||||
@@ -532,7 +531,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
|
||||
@@ -814,7 +814,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,
|
||||
@@ -5271,6 +5272,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
|
||||
}
|
||||
@@ -5351,7 +5353,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.')
|
||||
@@ -5364,6 +5370,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 }
|
||||
}
|
||||
@@ -5423,6 +5430,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
|
||||
}
|
||||
@@ -5640,7 +5648,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(
|
||||
@@ -5648,7 +5661,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])
|
||||
})
|
||||
})
|
||||
@@ -6682,6 +6682,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(
|
||||
@@ -6690,7 +6746,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' },
|
||||
@@ -6700,6 +6757,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
|
||||
data: {
|
||||
project: {
|
||||
id: 'project-1',
|
||||
host: 'ghe.example:8443',
|
||||
owner: 'acme',
|
||||
ownerType: 'organization',
|
||||
number: 1,
|
||||
@@ -6759,6 +6817,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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user