diff --git a/src/main/github/github-remote-identity-parsing.test.ts b/src/main/github/github-remote-identity-parsing.test.ts new file mode 100644 index 00000000000..c0b8b599520 --- /dev/null +++ b/src/main/github/github-remote-identity-parsing.test.ts @@ -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' + }) + }) +}) diff --git a/src/main/github/github-remote-identity-parsing.ts b/src/main/github/github-remote-identity-parsing.ts index e04683dd931..6f13e62014d 100644 --- a/src/main/github/github-remote-identity-parsing.ts +++ b/src/main/github/github-remote-identity-parsing.ts @@ -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 | 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 } diff --git a/src/main/github/project-view.test.ts b/src/main/github/project-view.test.ts index bde450d8108..edfeffb46a1 100644 --- a/src/main/github/project-view.test.ts +++ b/src/main/github/project-view.test.ts @@ -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() diff --git a/src/main/github/project-view/mutations.test.ts b/src/main/github/project-view/mutations.test.ts index 5272f03acb3..fc6c1014041 100644 --- a/src/main/github/project-view/mutations.test.ts +++ b/src/main/github/project-view/mutations.test.ts @@ -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' } ) }) diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index b42833cd63b..72929ec4b82 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -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 { diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index bf55cfb07e7..fa79b91ca69 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -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 = { diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 425c3fd043a..d924c3d40b1 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -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 { 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 diff --git a/src/renderer/src/components/github-project/ProjectCell.tsx b/src/renderer/src/components/github-project/ProjectCell.tsx index b6875672697..305fccc3873 100644 --- a/src/renderer/src/components/github-project/ProjectCell.tsx +++ b/src/renderer/src/components/github-project/ProjectCell.tsx @@ -43,6 +43,7 @@ type Props = { onEditLabels?: (add: string[], remove: string[]) => void onEditIssueType?: (issueType: GitHubIssueType | null) => void onOpenDialog?: () => void + sourceHost?: string sourceSettings: Pick | 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({ @@ -81,6 +84,7 @@ export default function ProjectCell({ @@ -92,6 +96,7 @@ export default function ProjectCell({ @@ -263,11 +268,13 @@ function TitleCell({ function TypeCell({ row, editable, + sourceHost, sourceSettings, onEditIssueType }: { row: GitHubProjectRow editable: boolean + sourceHost?: string sourceSettings: Pick | null | undefined onEditIssueType?: (issueType: GitHubIssueType | null) => void }): React.JSX.Element { @@ -279,6 +286,7 @@ function TypeCell({ @@ -311,11 +319,13 @@ function TypeCell({ function IssueTypeCell({ row, editable, + sourceHost, sourceSettings, onEditIssueType }: { row: GitHubProjectRow editable: boolean + sourceHost?: string sourceSettings: Pick | 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( 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 = ( @@ -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 | 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 | 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) => ) diff --git a/src/renderer/src/components/github-project/ProjectPicker.tsx b/src/renderer/src/components/github-project/ProjectPicker.tsx index 25519bc8f0b..2bd7d516eca 100644 --- a/src/renderer/src/components/github-project/ProjectPicker.tsx +++ b/src/renderer/src/components/github-project/ProjectPicker.tsx @@ -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) { diff --git a/src/renderer/src/components/github-project/ProjectRow.tsx b/src/renderer/src/components/github-project/ProjectRow.tsx index 8f23fca3e12..e59c58903eb 100644 --- a/src/renderer/src/components/github-project/ProjectRow.tsx +++ b/src/renderer/src/components/github-project/ProjectRow.tsx @@ -34,6 +34,7 @@ type Props = { onEditIssueType?: (issueType: GitHubIssueType | null) => void onStartWork?: () => void onOpenInBrowser?: () => void + sourceHost?: string sourceSettings: Pick | 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} /> diff --git a/src/renderer/src/components/github-project/ProjectViewList.tsx b/src/renderer/src/components/github-project/ProjectViewList.tsx index 76752d3c314..fbc869b1107 100644 --- a/src/renderer/src/components/github-project/ProjectViewList.tsx +++ b/src/renderer/src/components/github-project/ProjectViewList.tsx @@ -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} /> )) diff --git a/src/renderer/src/components/github-project/github-project-picker-filter.test.ts b/src/renderer/src/components/github-project/github-project-picker-filter.test.ts index 374973d55f7..2f04b73f71f 100644 --- a/src/renderer/src/components/github-project/github-project-picker-filter.test.ts +++ b/src/renderer/src/components/github-project/github-project-picker-filter.test.ts @@ -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) diff --git a/src/renderer/src/components/github-project/project-picker-input.test.ts b/src/renderer/src/components/github-project/project-picker-input.test.ts new file mode 100644 index 00000000000..87be9c5bada --- /dev/null +++ b/src/renderer/src/components/github-project/project-picker-input.test.ts @@ -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 }) + }) +}) diff --git a/src/renderer/src/components/github-project/project-row-filtering.test.ts b/src/renderer/src/components/github-project/project-row-filtering.test.ts index f70b44be4a6..708cef2fac5 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.test.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.test.ts @@ -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'), diff --git a/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx b/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx index ded043e3d68..b609030dc03 100644 --- a/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx @@ -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 | null | undefined @@ -29,7 +31,8 @@ export function AssigneesEditor({ open ? owner : null, open ? repo : null, seedKey ? seedKey.split(',') : [], - sourceSettings + sourceSettings, + host ) return ( !disabled && setOpen(o)}> diff --git a/src/renderer/src/components/github-project/slug-dialog/Comments.tsx b/src/renderer/src/components/github-project/slug-dialog/Comments.tsx index 95470618a81..6de67e2ca54 100644 --- a/src/renderer/src/components/github-project/slug-dialog/Comments.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/Comments.tsx @@ -24,11 +24,11 @@ function getRuntimeTarget(settings: Parameters[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 | 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 (
@@ -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 | 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( target, diff --git a/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx b/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx index 1c87fc3fd28..877cf47ca7c 100644 --- a/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx @@ -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 | null | undefined onChange: (add: string[], remove: string[]) => void | Promise }): 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 ( !disabled && setOpen(o)}> diff --git a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx index ad7f9b5eb62..af94be760b8 100644 --- a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx @@ -24,7 +24,7 @@ export function SlugDialogBody({ sourceSettings: Pick | 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({ setDetails((d) => (d ? { ...d, comments: next } : d))} @@ -344,6 +347,7 @@ export function SlugDialogBody({ setDetails((d) => (d ? { ...d, comments: [...d.comments, c] } : d))} diff --git a/src/renderer/src/lib/repo-slug-cache.test.ts b/src/renderer/src/lib/repo-slug-cache.test.ts new file mode 100644 index 00000000000..c49a6cca13c --- /dev/null +++ b/src/renderer/src/lib/repo-slug-cache.test.ts @@ -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]) + }) +}) diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index bb6967bd651..6986b1fd154 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -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' } diff --git a/src/shared/github-project-identity.test.ts b/src/shared/github-project-identity.test.ts new file mode 100644 index 00000000000..09d2f7f90d8 --- /dev/null +++ b/src/shared/github-project-identity.test.ts @@ -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') + }) +})