diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index 0fe26a5c3ee..82508136faf 100644 --- a/src/main/github/project-view.ts +++ b/src/main/github/project-view.ts @@ -55,6 +55,7 @@ import { GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR, isGitHubProjectRefInputTooLarge } from '../../shared/github-project-ref-input' +import { githubProjectHost } from '../../shared/github-project-identity' // Re-export the public API so existing call sites (`./project-view`) keep // working unchanged. The split is internal-only. @@ -1384,6 +1385,7 @@ export async function getProjectViewTable( const table: GitHubProjectTable = { project: { id: project.id, + host: githubProjectHost(args.host), owner: args.owner, ownerType: args.ownerType, number: args.projectNumber, @@ -1429,7 +1431,7 @@ type RawViewerDiscovery = { export async function listAccessibleProjects( args?: ListAccessibleProjectsArgs ): Promise { - const host = args?.host + const host = githubProjectHost(args?.host) const viewerProjects: GitHubProjectSummary[] = [] const orgProjects: GitHubProjectSummary[] = [] // Why: per-org failures are collected so the picker can render a "some orgs @@ -1488,6 +1490,7 @@ export async function listAccessibleProjects( n.owner?.__typename === 'Organization' ? 'organization' : 'user' viewerProjects.push({ id: n.id, + host, owner: ownerLogin, ownerType, number: n.number, @@ -1574,6 +1577,7 @@ export async function listAccessibleProjects( } orgProjects.push({ id: n.id, + host, owner: login, ownerType: 'organization', number: n.number, @@ -1794,6 +1798,7 @@ export async function resolveProjectRef( ownerType, number: parsed.number, title: p.title ?? '', + host: githubProjectHost(args.host), // Why: forward the parsed view number from /views/{n} URLs so the // renderer can skip the view-pick step. parsed.kind === 'bare' has no // viewNumber (owner/number shorthand carries no view). diff --git a/src/main/github/project-view/internals.ts b/src/main/github/project-view/internals.ts index 07a98827ad6..12db80e7dbe 100644 --- a/src/main/github/project-view/internals.ts +++ b/src/main/github/project-view/internals.ts @@ -12,6 +12,7 @@ import { type RateLimitBucketKind } from '../rate-limit' import type { GitHubProjectViewError } from '../../../shared/github-project-types' +import { githubProjectHost } from '../../../shared/github-project-identity' import { classifyProjectError, driftError, @@ -38,7 +39,7 @@ export type ProjectGhExecOptions = { cwd?: string; host?: string } // Why: owner/project-addressed calls carry only a host (no repo slug), so they // can't reuse githubHostExecOptions — mirror its host pinning for that shape. export function projectGhExecOptions(host: string | undefined): ProjectGhExecOptions { - return host ? { host } : {} + return { host: githubProjectHost(host) } } // ─── Slug validation ────────────────────────────────────────────────── diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index b5adcf617b4..e5f89c33cc4 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -111,7 +111,8 @@ const GitHubProjectRef = z .object({ owner: z.string(), ownerType: z.enum(['organization', 'user']), - number: z.number().int() + number: z.number().int(), + host: z.string().optional() }) .strict() const GitHubProjectSettings = z diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 202eb9ebae7..abc58f27a8a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1474,7 +1474,7 @@ export type PreloadApi = { repoSlug: (args: { repoPath: string repoId?: string - }) => Promise<{ owner: string; repo: string } | null> + }) => Promise<{ owner: string; repo: string; host?: string } | null> repoUpstream: (args: { repoPath: string repoId?: string diff --git a/src/renderer/src/components/github-project/ProjectPicker.tsx b/src/renderer/src/components/github-project/ProjectPicker.tsx index 3e11b00948c..25519bc8f0b 100644 --- a/src/renderer/src/components/github-project/ProjectPicker.tsx +++ b/src/renderer/src/components/github-project/ProjectPicker.tsx @@ -36,11 +36,16 @@ import { rememberProjectPickerBrowseCacheEntry } from './project-picker-browse-cache' import { translate } from '@/i18n/i18n' +import { + githubProjectHost, + githubProjectIdentityKey +} from '../../../../shared/github-project-identity' export type ResolvedProjectSelection = { owner: string ownerType: GitHubProjectOwnerType projectNumber: number + host?: string viewId?: string } @@ -49,6 +54,7 @@ type Props = { owner: string ownerType: GitHubProjectOwnerType number: number + host?: string title?: string } | null onSelect: (selection: ResolvedProjectSelection) => void @@ -65,19 +71,22 @@ async function listAccessibleProjectsForRuntime( settings: Parameters[0] ): Promise { const target = getActiveRuntimeTarget(settings) + const args = { host: 'github.com' } return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'github.project.listAccessible', - {}, - { timeoutMs: 60_000 } - ) - : window.api.gh.listAccessibleProjects() + ? callRuntimeRpc(target, 'github.project.listAccessible', args, { + timeoutMs: 60_000 + }) + : window.api.gh.listAccessibleProjects(args) } async function listProjectViewsForRuntime( settings: Parameters[0], - args: { owner: string; ownerType: GitHubProjectOwnerType; projectNumber: number } + args: { + owner: string + ownerType: GitHubProjectOwnerType + projectNumber: number + host?: string + } ): Promise { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' @@ -89,17 +98,18 @@ async function listProjectViewsForRuntime( async function resolveProjectRefForRuntime( settings: Parameters[0], - input: string + input: string, + host?: string ): Promise { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? callRuntimeRpc( target, 'github.project.resolveRef', - { input }, + { input, ...(host ? { host } : {}) }, { timeoutMs: 30_000 } ) - : window.api.gh.resolveProjectRef({ input }) + : window.api.gh.resolveProjectRef({ input, ...(host ? { host } : {}) }) } export default function ProjectPicker({ activeProject, onSelect }: Props): React.JSX.Element { @@ -203,16 +213,22 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React const commitSelection = useCallback( async (selection: ResolvedProjectSelection, title: string | null) => { - const key = `${selection.ownerType}:${selection.owner}:${selection.projectNumber}` + const key = githubProjectIdentityKey({ + owner: selection.owner, + ownerType: selection.ownerType, + number: selection.projectNumber, + host: selection.host + }) await updateProjectSettings((prev) => { const recent = [ { owner: selection.owner, ownerType: selection.ownerType, number: selection.projectNumber, + host: githubProjectHost(selection.host), lastOpenedAt: new Date().toISOString() }, - ...prev.recent.filter((r) => `${r.ownerType}:${r.owner}:${r.number}` !== key) + ...prev.recent.filter((r) => githubProjectIdentityKey(r) !== key) ].slice(0, 10) const lastViewByProject = { ...prev.lastViewByProject } if (selection.viewId) { @@ -225,7 +241,8 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React activeProject: { owner: selection.owner, ownerType: selection.ownerType, - number: selection.projectNumber + number: selection.projectNumber, + host: githubProjectHost(selection.host) } } }) @@ -246,13 +263,14 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React owner: string ownerType: GitHubProjectOwnerType number: number + host?: string title?: string // Why: when the paste resolver parsed a /views/{n} URL, the caller // passes the view number through so we can skip the view-pick step // and commit directly once listProjectViews returns the matching id. viewNumber?: number }) => { - const key = `${selection.ownerType}:${selection.owner}:${selection.number}` + const key = githubProjectIdentityKey(selection) const lastView = projectSettings.lastViewByProject[key]?.viewId // Why: an explicit viewNumber from the URL takes precedence over the // remembered last view — the user's intent (paste this exact view) wins @@ -263,6 +281,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React owner: selection.owner, ownerType: selection.ownerType, projectNumber: selection.number, + host: githubProjectHost(selection.host), viewId: lastView }, selection.title ?? null @@ -273,14 +292,16 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React setViewPickFor({ owner: selection.owner, ownerType: selection.ownerType, - projectNumber: selection.number + projectNumber: selection.number, + host: githubProjectHost(selection.host) }) setViewLoading(true) try { const res = await listProjectViewsForRuntime(settings, { owner: selection.owner, ownerType: selection.ownerType, - projectNumber: selection.number + projectNumber: selection.number, + host: githubProjectHost(selection.host) }) if (!mountedRef.current) { return @@ -299,6 +320,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React owner: selection.owner, ownerType: selection.ownerType, projectNumber: selection.number, + host: githubProjectHost(selection.host), viewId: match.id }, selection.title ?? null @@ -348,7 +370,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React setPasteError(null) setPasteBusy(true) try { - const res = await resolveProjectRefForRuntime(settings, input) + const res = await resolveProjectRefForRuntime(settings, input, parsed.host) if (!mountedRef.current) { return } @@ -361,6 +383,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React owner: res.owner, ownerType: res.ownerType, number: res.number, + host: githubProjectHost(res.host ?? parsed.host), title: res.title, // Why: forward the parsed view number from /views/{n} URLs so the // chooser can skip the view-pick step and commit directly. @@ -439,11 +462,9 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React )} > {projectSettings.pinned.map((p) => { - const key = `${p.ownerType}:${p.owner}:${p.number}` + const key = githubProjectIdentityKey(p) const knownGood = projectSettings.lastViewByProject[key]?.viewId != null - const match = browseProjects.find( - (bp) => `${bp.ownerType}:${bp.owner}:${bp.number}` === key - ) + const match = browseProjects.find((bp) => githubProjectIdentityKey(bp) === key) return ( { await updateProjectSettings((prev) => ({ ...prev, - pinned: prev.pinned.filter( - (x) => `${x.ownerType}:${x.owner}:${x.number}` !== key - ) + pinned: prev.pinned.filter((x) => githubProjectIdentityKey(x) !== key) })) }} /> @@ -482,16 +502,13 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React .filter( (r) => !projectSettings.pinned.some( - (p) => - p.ownerType === r.ownerType && - p.owner === r.owner && - p.number === r.number + (p) => githubProjectIdentityKey(p) === githubProjectIdentityKey(r) ) ) .map((r) => { - const key = `${r.ownerType}:${r.owner}:${r.number}` + const key = githubProjectIdentityKey(r) const match = browseProjects.find( - (bp) => `${bp.ownerType}:${bp.owner}:${bp.number}` === key + (bp) => githubProjectIdentityKey(bp) === key ) const pinnable = projectSettings.lastViewByProject[key]?.viewId != null return ( @@ -505,7 +522,12 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React ...prev, pinned: [ ...prev.pinned, - { owner: r.owner, ownerType: r.ownerType, number: r.number } + { + owner: r.owner, + ownerType: r.ownerType, + number: r.number, + host: githubProjectHost(r.host) + } ].slice(0, 20) })) }} @@ -514,6 +536,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React owner: r.owner, ownerType: r.ownerType, number: r.number, + host: githubProjectHost(r.host), title: match?.title }) } @@ -546,7 +569,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React ) : null} {filteredBrowse.map((p) => ( @@ -554,6 +577,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React owner: p.owner, ownerType: p.ownerType, number: p.number, + host: githubProjectHost(p.host), title: p.title }) } @@ -803,7 +827,7 @@ function AuthErrorBanner({ error }: { error: GitHubProjectViewError }): React.JS function parseProjectInput( input: string -): { owner: string; number: number; viewNumber?: number } | null { +): { owner: string; number: number; host?: string; viewNumber?: number } | null { if (!input) { return null } @@ -817,7 +841,7 @@ function parseProjectInput( } try { const url = new URL(input) - if (url.hostname !== 'github.com') { + if ((url.protocol !== 'https:' && url.protocol !== 'http:') || !url.host) { return null } const parts = url.pathname.split('/').filter(Boolean) @@ -835,7 +859,9 @@ function parseProjectInput( viewNumber = v } } - return { owner, number, viewNumber } + // Why: URL.host preserves non-default GHES ports; URL.hostname would + // silently route a project on :8443 to the server's default port. + return { owner, number, host: url.host, viewNumber } } } catch { return null diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index 09578f05c2f..0b8ccdb3250 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -63,6 +63,10 @@ import { } from './project-visible-table-cache' import { translate } from '@/i18n/i18n' import { buildTaskSourceContextFromRepo } from '../../../../shared/task-source-context' +import { + githubProjectHost, + githubProjectIdentityKey +} from '../../../../shared/github-project-identity' type Props = { selectedRepoIds: ReadonlySet @@ -72,7 +76,12 @@ const ORCA_FEATURE_REQUEST_URL = 'https://github.com/stablyai/orca/issues/new' function listProjectViewsForRuntime( settings: Parameters[0], - args: { owner: string; ownerType: 'organization' | 'user'; projectNumber: number } + args: { + owner: string + ownerType: 'organization' | 'user' + projectNumber: number + host?: string + } ): Promise { const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' @@ -146,6 +155,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS owner: selection.owner, ownerType: selection.ownerType, projectNumber: selection.projectNumber, + host: githubProjectHost(selection.host), ...(selection.viewId ? { viewId: selection.viewId } : {}), ...(queryOverride !== undefined ? { queryOverride } : {}) }, @@ -180,7 +190,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS if (!activeProject) { return } - const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const key = githubProjectIdentityKey(activeProject) const viewId = lastViewByProject[key]?.viewId if (!viewId) { return @@ -193,7 +203,8 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS activeProject.number, viewId, queryOverride, - projectViewSourceScope + projectViewSourceScope, + activeProject.host ) if (projectViewCache[cacheKey]?.data) { return @@ -203,6 +214,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS owner: activeProject.owner, ownerType: activeProject.ownerType, projectNumber: activeProject.number, + host: githubProjectHost(activeProject.host), viewId }, false, @@ -224,7 +236,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS if (!activeProject) { return } - const projectKey = `${projectViewSourceScope}:${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const projectKey = `${projectViewSourceScope}:${githubProjectIdentityKey(activeProject)}` if (viewListByProject[projectKey]) { return } @@ -232,7 +244,8 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS void listProjectViewsForRuntime(settings, { owner: activeProject.owner, ownerType: activeProject.ownerType, - projectNumber: activeProject.number + projectNumber: activeProject.number, + host: githubProjectHost(activeProject.host) }) .then((res) => { if (cancelled) { @@ -262,7 +275,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS if (!activeProject) { return } - const projectKey = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const projectKey = githubProjectIdentityKey(activeProject) const current = lastViewByProject[projectKey]?.viewId if (current === viewId) { return @@ -292,6 +305,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS owner: activeProject.owner, ownerType: activeProject.ownerType, projectNumber: activeProject.number, + host: githubProjectHost(activeProject.host), viewId }) }, @@ -302,7 +316,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS if (!activeProject) { return null } - const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const key = githubProjectIdentityKey(activeProject) const viewId = lastViewByProject[key]?.viewId if (!viewId) { return null @@ -318,7 +332,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS if (!activeProject) { return null } - const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const key = githubProjectIdentityKey(activeProject) const viewId = lastViewByProject[key]?.viewId if (!viewId) { return null @@ -329,7 +343,8 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS activeProject.number, viewId, currentAppliedOverride, - projectViewSourceScope + projectViewSourceScope, + activeProject.host ) }, [activeProject, lastViewByProject, currentAppliedOverride, projectViewSourceScope]) @@ -504,6 +519,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS return { owner, repo, + host: githubProjectHost(table.project.host), number: row.content.number, type: row.itemType === 'PULL_REQUEST' ? 'pr' : 'issue', projectId: table.project.id, @@ -537,6 +553,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS const resolution = resolveSelectedProjectRowRepo({ row, lookupSlug, + host: table.project.host, slugIndexReady, selectedRepoIds }) @@ -764,13 +781,15 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS owner: activeProject.owner, ownerType: activeProject.ownerType, number: activeProject.number, + host: githubProjectHost(activeProject.host), title: table.project.title } : activeProject ? { owner: activeProject.owner, ownerType: activeProject.ownerType, - number: activeProject.number + number: activeProject.number, + host: githubProjectHost(activeProject.host) } : null } @@ -790,7 +809,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS if (!activeProject) { return } - const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const key = githubProjectIdentityKey(activeProject) const viewId = lastViewByProject[key]?.viewId if (!viewId) { return @@ -812,6 +831,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS owner: activeProject.owner, ownerType: activeProject.ownerType, projectNumber: activeProject.number, + host: githubProjectHost(activeProject.host), viewId }, true, @@ -847,7 +867,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS if (!activeProject || !currentCacheKey) { return } - const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const key = githubProjectIdentityKey(activeProject) const viewId = lastViewByProject[key]?.viewId if (!viewId) { return @@ -857,6 +877,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS owner: activeProject.owner, ownerType: activeProject.ownerType, projectNumber: activeProject.number, + host: githubProjectHost(activeProject.host), viewId }, true, @@ -896,7 +917,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS {activeProject ? (() => { - const projectKey = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const projectKey = githubProjectIdentityKey(activeProject) const scopedProjectKey = `${projectViewSourceScope}:${projectKey}` const views = viewListByProject[scopedProjectKey] ?? [] const activeViewId = lastViewByProject[projectKey]?.viewId ?? null diff --git a/src/renderer/src/components/github-project/github-project-picker-filter.ts b/src/renderer/src/components/github-project/github-project-picker-filter.ts index 1784acce5d4..0f8065db291 100644 --- a/src/renderer/src/components/github-project/github-project-picker-filter.ts +++ b/src/renderer/src/components/github-project/github-project-picker-filter.ts @@ -3,6 +3,7 @@ import type { GitHubProjectSettings, GitHubProjectSummary } from '../../../../shared/github-project-types' +import { githubProjectIdentityKey } from '../../../../shared/github-project-identity' export const GITHUB_PROJECT_PICKER_QUERY_MAX_BYTES = 2 * 1024 @@ -14,9 +15,9 @@ export function isGitHubProjectPickerQueryTooLarge( } function getProjectKey( - project: Pick + project: Pick ): string { - return `${project.ownerType}:${project.owner}:${project.number}` + return githubProjectIdentityKey(project) } export function filterGitHubProjectPickerProjects({ diff --git a/src/renderer/src/components/github-project/project-row-filtering.ts b/src/renderer/src/components/github-project/project-row-filtering.ts index a0c23c20d66..1aa2f4c3ed8 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.ts @@ -1,7 +1,10 @@ import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github-project-types' import type { Repo } from '../../../../shared/types' -export type ProjectRowSlugLookup = (slug: string | null | undefined) => readonly Repo[] +export type ProjectRowSlugLookup = ( + slug: string | null | undefined, + host?: string +) => readonly Repo[] export type SelectedProjectRowResolution = | { status: 'loading' } @@ -18,6 +21,7 @@ export type SelectedProjectRowResolution = export function resolveSelectedProjectRowRepo(input: { row: GitHubProjectRow lookupSlug: ProjectRowSlugLookup + host?: string slugIndexReady: boolean selectedRepoIds: ReadonlySet }): SelectedProjectRowResolution { @@ -34,7 +38,7 @@ export function resolveSelectedProjectRowRepo(input: { return { status: 'invalid_slug' } } - const globalMatches = input.lookupSlug(repository) + const globalMatches = input.lookupSlug(repository, input.host) if (globalMatches.length === 0) { return { status: 'no_global_match' } } @@ -51,16 +55,19 @@ export function resolveSelectedProjectRowRepo(input: { export function projectRowHasOpenRepo( row: GitHubProjectRow, - lookupSlug: ProjectRowSlugLookup + lookupSlug: ProjectRowSlugLookup, + host?: string ): boolean { - return lookupSlug(row.content.repository).length > 0 + return lookupSlug(row.content.repository, host).length > 0 } export function filterProjectTableRowsByOpenRepos( table: GitHubProjectTable, lookupSlug: ProjectRowSlugLookup ): GitHubProjectTable { - const rows = table.rows.filter((row) => projectRowHasOpenRepo(row, lookupSlug)) + const rows = table.rows.filter((row) => + projectRowHasOpenRepo(row, lookupSlug, table.project.host) + ) if (rows.length === table.rows.length && table.totalCount === rows.length) { return table } @@ -77,6 +84,7 @@ export function filterProjectTableRowsBySelectedRepos( const resolution = resolveSelectedProjectRowRepo({ row, lookupSlug, + host: table.project.host, slugIndexReady, selectedRepoIds }) diff --git a/src/renderer/src/lib/repo-slug-cache.ts b/src/renderer/src/lib/repo-slug-cache.ts index 193d871eae1..a4407f8eb1b 100644 --- a/src/renderer/src/lib/repo-slug-cache.ts +++ b/src/renderer/src/lib/repo-slug-cache.ts @@ -4,6 +4,7 @@ import type { GlobalSettings, Repo } from '../../../shared/types' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getSettingsForRepoRuntimeOwner } from './repo-runtime-owner' +import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' /** Lowercased `owner/repo` → Repo[]. */ export type SlugIndex = Map @@ -34,16 +35,18 @@ export function settingsForRepoOwner( export function lookupReposBySlugFromCache( repos: readonly Repo[], settings: Pick | null | undefined, - slug: string | null | undefined + slug: string | null | undefined, + host?: string ): Repo[] { - const target = slug?.toLowerCase() - if (!target) { + const [owner, repo] = slug?.split('/') ?? [] + if (!owner || !repo) { return [] } + const target = githubRepoIdentityKey({ owner, repo, host }) const matched: Repo[] = [] for (const repo of repos) { const cacheKey = slugCacheKey(repo.id, settingsForRepoOwner(repo, settings)) - if (slugByRepoId.get(cacheKey)?.toLowerCase() === target) { + if (slugByRepoId.get(cacheKey) === target) { matched.push(repo) } } diff --git a/src/renderer/src/lib/repo-slug-index.ts b/src/renderer/src/lib/repo-slug-index.ts index 8a4832b04fa..dcfed6bf9bb 100644 --- a/src/renderer/src/lib/repo-slug-index.ts +++ b/src/renderer/src/lib/repo-slug-index.ts @@ -19,6 +19,7 @@ import type { Repo } from '../../../shared/types' import type { GlobalSettings } from '../../../shared/types' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { settingsForRepoOwner, slugByRepoId, slugCacheKey, type SlugIndex } from './repo-slug-cache' +import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' export { lookupReposBySlugFromCache } from './repo-slug-cache' @@ -50,7 +51,7 @@ async function resolveRepoSlug( const target = getActiveRuntimeTarget(settings) const result = target.kind === 'environment' - ? await callRuntimeRpc<{ owner: string; repo: string } | null>( + ? await callRuntimeRpc<{ owner: string; repo: string; host?: string } | null>( target, 'github.repoSlug', { repo: repo.id }, @@ -61,7 +62,7 @@ async function resolveRepoSlug( slugByRepoId.set(cacheKey, null) return null } - const slug = `${result.owner}/${result.repo}`.toLowerCase() + const slug = githubRepoIdentityKey(result) slugByRepoId.set(cacheKey, slug) return slug } catch { @@ -104,7 +105,7 @@ async function buildIndex( } export type RepoSlugIndexState = { - lookupSlug: (slug: string | null | undefined) => Repo[] + lookupSlug: (slug: string | null | undefined, host?: string) => Repo[] ready: boolean } @@ -134,11 +135,12 @@ export function useRepoSlugIndex(): RepoSlugIndexState { return useMemo( () => ({ - lookupSlug: (slug: string | null | undefined): Repo[] => { - if (!slug) { + lookupSlug: (slug: string | null | undefined, host?: string): Repo[] => { + const [owner, repo] = slug?.split('/') ?? [] + if (!owner || !repo) { return [] } - return index.get(slug.toLowerCase()) ?? [] + return index.get(githubRepoIdentityKey({ owner, repo, host })) ?? [] }, ready }), diff --git a/src/renderer/src/lib/smart-github-submit.test.ts b/src/renderer/src/lib/smart-github-submit.test.ts index 64e58824e0c..e80d0ec8de0 100644 --- a/src/renderer/src/lib/smart-github-submit.test.ts +++ b/src/renderer/src/lib/smart-github-submit.test.ts @@ -11,6 +11,7 @@ describe('getSmartGitHubSubmitIntent', () => { it('treats GitHub issue and pull URLs as submit-time source intent', () => { expect(getSmartGitHubSubmitIntent('https://github.com/stablyai/orca/pull/2049')).toEqual({ kind: 'link', + host: 'github.com', owner: 'stablyai', repo: 'orca', number: 2049, @@ -18,6 +19,7 @@ describe('getSmartGitHubSubmitIntent', () => { }) expect(getSmartGitHubSubmitIntent('https://github.com/stablyai/orca/issues/2050')).toEqual({ kind: 'link', + host: 'github.com', owner: 'stablyai', repo: 'orca', number: 2050, @@ -32,6 +34,7 @@ describe('getSmartGitHubSubmitIntent', () => { ) ).toEqual({ kind: 'link', + host: 'github.com', owner: 'mvanhorn', repo: 'cli-printing-press', number: 2635, @@ -44,6 +47,7 @@ describe('getSmartGitHubSubmitIntent', () => { getSmartGitHubSubmitIntent('review (https://github.com/stablyai/orca/pull/2049), please') ).toEqual({ kind: 'link', + host: 'github.com', owner: 'stablyai', repo: 'orca', number: 2049, @@ -53,6 +57,7 @@ describe('getSmartGitHubSubmitIntent', () => { expect(getSmartGitHubSubmitIntent('fix https://github.com/stablyai/orca/issues/2050.')).toEqual( { kind: 'link', + host: 'github.com', owner: 'stablyai', repo: 'orca', number: 2050, diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index a2ba27bc941..3ce037a4e24 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -4,6 +4,7 @@ import type { StateCreator } from 'zustand' import { toast } from 'sonner' import type { AppState } from '../types' import { githubRepoIdentityKey } from '../../../../shared/github-repository-identity-key' +import { githubProjectIdentityKey } from '../../../../shared/github-project-identity' import type { ClassifiedError, GitHubOwnerRepo, @@ -423,9 +424,11 @@ export function projectViewCacheKey( projectNumber: number, resolvedViewId: string, queryOverride?: string, - sourceScope = 'local' + sourceScope = 'local', + host?: string ): string { - return `github-project:${sourceScope}:${ownerType}:${owner}:${projectNumber}:${resolvedViewId}${queryOverrideKeyPart(queryOverride)}` + const projectKey = githubProjectIdentityKey({ ownerType, owner, number: projectNumber, host }) + return `github-project:${sourceScope}:${projectKey}:${resolvedViewId}${queryOverrideKeyPart(queryOverride)}` } function projectViewRequestKey(args: GetProjectViewTableArgs, sourceScope: string): string { @@ -439,7 +442,13 @@ function projectViewRequestKey(args: GetProjectViewTableArgs, sourceScope: strin : args.viewName ? `name:${args.viewName}` : 'default' - return `${sourceScope}:${args.ownerType}:${args.owner}:${args.projectNumber}:${selector}${queryOverrideKeyPart(args.queryOverride)}` + const projectKey = githubProjectIdentityKey({ + ownerType: args.ownerType, + owner: args.owner, + number: args.projectNumber, + host: args.host + }) + return `${sourceScope}:${projectKey}:${selector}${queryOverrideKeyPart(args.queryOverride)}` } function projectViewSourceScope(settings: AppState['settings']): string { @@ -2206,7 +2215,8 @@ export const createGitHubSlice: StateCreator = (s args.projectNumber, args.viewId, args.queryOverride, - sourceScope + sourceScope, + args.host ) : null if (!options?.force && maybeKnownKey) { @@ -2248,7 +2258,8 @@ export const createGitHubSlice: StateCreator = (s table.project.number, table.selectedView.id, args.queryOverride, - sourceScope + sourceScope, + table.project.host ) set((s) => ({ projectViewCache: withBoundedCacheEntry(s.projectViewCache, key, { @@ -2338,6 +2349,7 @@ export const createGitHubSlice: StateCreator = (s 'github.project.updateItemField', { projectId: table.project.id, + host: table.project.host, itemId: rowId, fieldId, value @@ -2346,6 +2358,7 @@ export const createGitHubSlice: StateCreator = (s ) : await window.api.gh.updateProjectItemField({ projectId: table.project.id, + host: table.project.host, itemId: rowId, fieldId, value @@ -2396,6 +2409,7 @@ export const createGitHubSlice: StateCreator = (s 'github.project.clearItemField', { projectId: table.project.id, + host: table.project.host, itemId: rowId, fieldId }, @@ -2403,6 +2417,7 @@ export const createGitHubSlice: StateCreator = (s ) : await window.api.gh.clearProjectItemField({ projectId: table.project.id, + host: table.project.host, itemId: rowId, fieldId }) @@ -2506,6 +2521,7 @@ export const createGitHubSlice: StateCreator = (s const args = { owner, repo, + host: table.project.host, number, updates: { ...(updates.title !== undefined ? { title: updates.title } : {}), @@ -2537,6 +2553,7 @@ export const createGitHubSlice: StateCreator = (s const args = { owner, repo, + host: table.project.host, number, updates: { ...(updates.title !== undefined ? { title: updates.title } : {}), @@ -2630,6 +2647,7 @@ export const createGitHubSlice: StateCreator = (s const args = { owner, repo, + host: table.project.host, number, issueTypeId: issueType?.id ?? null } diff --git a/src/shared/github-project-identity.ts b/src/shared/github-project-identity.ts new file mode 100644 index 00000000000..426d8e837b3 --- /dev/null +++ b/src/shared/github-project-identity.ts @@ -0,0 +1,24 @@ +import { isDefaultGitHubHost } from './github-repository-identity-key' + +export type GitHubProjectIdentity = { + owner: string + ownerType: 'organization' | 'user' + number: number + host?: string +} + +/** Stable settings/cache identity. Default-host keys intentionally retain the + * legacy shape so existing github.com project preferences survive upgrades. */ +export function githubProjectIdentityKey(project: GitHubProjectIdentity): string { + const projectKey = `${project.ownerType}:${project.owner.toLowerCase()}:${project.number}` + return project.host && !isDefaultGitHubHost(project.host) + ? `${project.host.toLowerCase()}:${projectKey}` + : projectKey +} + +/** Project API calls must pin github.com too; otherwise process GH_HOST can + * redirect a host-less persisted project to an Enterprise server. */ +export function githubProjectHost(host?: string | null): string { + const trimmed = host?.trim() + return trimmed || 'github.com' +} diff --git a/src/shared/github-project-types.ts b/src/shared/github-project-types.ts index 960a061d8ed..9175c4b27b1 100644 --- a/src/shared/github-project-types.ts +++ b/src/shared/github-project-types.ts @@ -184,6 +184,7 @@ export type GitHubProjectRow = { export type GitHubProjectTable = { project: { id: string + host?: string owner: string ownerType: GitHubProjectOwnerType number: number @@ -201,6 +202,7 @@ export type GitHubProjectTable = { export type GitHubProjectSummary = { id: string + host?: string owner: string ownerType: GitHubProjectOwnerType number: number @@ -217,15 +219,21 @@ export type GitHubProjectViewSummary = { } export type GitHubProjectSettings = { - pinned: { owner: string; ownerType: GitHubProjectOwnerType; number: number }[] + pinned: { owner: string; ownerType: GitHubProjectOwnerType; number: number; host?: string }[] recent: { owner: string ownerType: GitHubProjectOwnerType number: number + host?: string lastOpenedAt: string }[] lastViewByProject: Record - activeProject: { owner: string; ownerType: GitHubProjectOwnerType; number: number } | null + activeProject: { + owner: string + ownerType: GitHubProjectOwnerType + number: number + host?: string + } | null } // ─── Classified errors ───────────────────────────────────────────────── @@ -279,6 +287,7 @@ export type ResolveProjectRefResult = ownerType: GitHubProjectOwnerType number: number title: string + host?: string // Why: when the input is a /views/{n} URL, surface the parsed view // number so the picker can skip the view-pick step and commit the // selection directly. Absent for owner/number shorthand and project