Carry host on smart-submit link intents

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-20 12:35:26 -07:00
co-authored by Orca
parent e5281f5c26
commit b31cc77bd4
14 changed files with 201 additions and 77 deletions
+6 -1
View File
@@ -50,6 +50,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 `./project-view` call sites keep working; the split is internal-only.
export { isValidOwnerSlug, isValidRepoSlug, isValidSlug } from './project-view/internals'
@@ -1318,6 +1319,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,
@@ -1363,7 +1365,7 @@ type RawViewerDiscovery = {
export async function listAccessibleProjects(
args?: ListAccessibleProjectsArgs
): Promise<ListAccessibleProjectsResult> {
const host = args?.host
const host = githubProjectHost(args?.host)
const viewerProjects: GitHubProjectSummary[] = []
const orgProjects: GitHubProjectSummary[] = []
// Why: collect per-org failures so the picker shows a "some orgs didn't load" banner instead of aborting discovery on the first 504.
@@ -1416,6 +1418,7 @@ export async function listAccessibleProjects(
n.owner?.__typename === 'Organization' ? 'organization' : 'user'
viewerProjects.push({
id: n.id,
host,
owner: ownerLogin,
ownerType,
number: n.number,
@@ -1490,6 +1493,7 @@ export async function listAccessibleProjects(
}
orgProjects.push({
id: n.id,
host,
owner: login,
ownerType: 'organization',
number: n.number,
@@ -1710,6 +1714,7 @@ export async function resolveProjectRef(
ownerType,
number: parsed.number,
title: p.title ?? '',
host: githubProjectHost(args.host),
// Why: forward the view number from /views/{n} URLs so the renderer can skip the view-pick step (bare owner/number shorthand carries none).
...(parsed.kind !== 'bare' && parsed.viewNumber !== undefined
? { viewNumber: parsed.viewNumber }
+2 -1
View File
@@ -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 ──────────────────────────────────────────────────
@@ -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
+1 -1
View File
@@ -1477,7 +1477,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
@@ -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<typeof getActiveRuntimeTarget>[0]
): Promise<ListAccessibleProjectsResult> {
const target = getActiveRuntimeTarget(settings)
const args = { host: 'github.com' }
return target.kind === 'environment'
? callRuntimeRpc<ListAccessibleProjectsResult>(
target,
'github.project.listAccessible',
{},
{ timeoutMs: 60_000 }
)
: window.api.gh.listAccessibleProjects()
? callRuntimeRpc<ListAccessibleProjectsResult>(target, 'github.project.listAccessible', args, {
timeoutMs: 60_000
})
: window.api.gh.listAccessibleProjects(args)
}
async function listProjectViewsForRuntime(
settings: Parameters<typeof getActiveRuntimeTarget>[0],
args: { owner: string; ownerType: GitHubProjectOwnerType; projectNumber: number }
args: {
owner: string
ownerType: GitHubProjectOwnerType
projectNumber: number
host?: string
}
): Promise<ListProjectViewsResult> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
@@ -89,17 +98,18 @@ async function listProjectViewsForRuntime(
async function resolveProjectRefForRuntime(
settings: Parameters<typeof getActiveRuntimeTarget>[0],
input: string
input: string,
host?: string
): Promise<ResolveProjectRefResult> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<ResolveProjectRefResult>(
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 (
<PickerRow
key={key}
@@ -455,15 +476,14 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
owner: p.owner,
ownerType: p.ownerType,
number: p.number,
host: githubProjectHost(p.host),
title: match?.title
})
}
onRemovePin={async () => {
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) => (
<PickerRow
key={`${p.ownerType}:${p.owner}:${p.number}`}
key={githubProjectIdentityKey(p)}
title={p.title}
subtitle={p.owner}
onClick={() =>
@@ -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
@@ -61,6 +61,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<string>
@@ -70,7 +74,12 @@ const ORCA_FEATURE_REQUEST_URL = 'https://github.com/stablyai/orca/issues/new'
function listProjectViewsForRuntime(
settings: Parameters<typeof getActiveRuntimeTarget>[0],
args: { owner: string; ownerType: 'organization' | 'user'; projectNumber: number }
args: {
owner: string
ownerType: 'organization' | 'user'
projectNumber: number
host?: string
}
): Promise<ListProjectViewsResult> {
const target = getActiveRuntimeTarget(settings)
return target.kind === 'environment'
@@ -134,6 +143,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 } : {})
},
@@ -167,7 +177,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
@@ -180,7 +190,8 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS
activeProject.number,
viewId,
queryOverride,
projectViewSourceScope
projectViewSourceScope,
activeProject.host
)
if (projectViewCache[cacheKey]?.data) {
return
@@ -190,6 +201,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS
owner: activeProject.owner,
ownerType: activeProject.ownerType,
projectNumber: activeProject.number,
host: githubProjectHost(activeProject.host),
viewId
},
false,
@@ -209,7 +221,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
}
@@ -217,7 +229,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) {
@@ -246,7 +259,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
@@ -272,6 +285,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS
owner: activeProject.owner,
ownerType: activeProject.ownerType,
projectNumber: activeProject.number,
host: githubProjectHost(activeProject.host),
viewId
})
},
@@ -282,7 +296,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
@@ -298,7 +312,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
@@ -309,7 +323,8 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS
activeProject.number,
viewId,
currentAppliedOverride,
projectViewSourceScope
projectViewSourceScope,
activeProject.host
)
}, [activeProject, lastViewByProject, currentAppliedOverride, projectViewSourceScope])
@@ -473,6 +488,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,
@@ -506,6 +522,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS
const resolution = resolveSelectedProjectRowRepo({
row,
lookupSlug,
host: table.project.host,
slugIndexReady,
selectedRepoIds
})
@@ -729,13 +746,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
}
@@ -751,7 +770,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
@@ -771,6 +790,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS
owner: activeProject.owner,
ownerType: activeProject.ownerType,
projectNumber: activeProject.number,
host: githubProjectHost(activeProject.host),
viewId
},
true,
@@ -806,7 +826,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
@@ -816,6 +836,7 @@ export default function ProjectViewWrapper({ selectedRepoIds }: Props): React.JS
owner: activeProject.owner,
ownerType: activeProject.ownerType,
projectNumber: activeProject.number,
host: githubProjectHost(activeProject.host),
viewId
},
true,
@@ -855,7 +876,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
@@ -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<GitHubProjectSummary, 'ownerType' | 'owner' | 'number'>
project: Pick<GitHubProjectSummary, 'ownerType' | 'owner' | 'number' | 'host'>
): string {
return `${project.ownerType}:${project.owner}:${project.number}`
return githubProjectIdentityKey(project)
}
export function filterGitHubProjectPickerProjects({
@@ -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<string>
}): 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
})
+7 -4
View File
@@ -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<string, Repo[]>
@@ -34,16 +35,18 @@ export function settingsForRepoOwner(
export function lookupReposBySlugFromCache(
repos: readonly Repo[],
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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)
}
}
+8 -6
View File
@@ -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
}),
@@ -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,
+23 -5
View File
@@ -3,6 +3,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,
@@ -419,9 +420,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 {
@@ -433,7 +436,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 {
@@ -2082,7 +2091,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
args.projectNumber,
args.viewId,
args.queryOverride,
sourceScope
sourceScope,
args.host
)
: null
if (!options?.force && maybeKnownKey) {
@@ -2122,7 +2132,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
table.project.number,
table.selectedView.id,
args.queryOverride,
sourceScope
sourceScope,
table.project.host
)
set((s) => ({
projectViewCache: withBoundedCacheEntry(s.projectViewCache, key, {
@@ -2208,6 +2219,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
'github.project.updateItemField',
{
projectId: table.project.id,
host: table.project.host,
itemId: rowId,
fieldId,
value
@@ -2216,6 +2228,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
)
: await window.api.gh.updateProjectItemField({
projectId: table.project.id,
host: table.project.host,
itemId: rowId,
fieldId,
value
@@ -2266,6 +2279,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
'github.project.clearItemField',
{
projectId: table.project.id,
host: table.project.host,
itemId: rowId,
fieldId
},
@@ -2273,6 +2287,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
)
: await window.api.gh.clearProjectItemField({
projectId: table.project.id,
host: table.project.host,
itemId: rowId,
fieldId
})
@@ -2372,6 +2387,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const args = {
owner,
repo,
host: table.project.host,
number,
updates: {
...(updates.title !== undefined ? { title: updates.title } : {}),
@@ -2403,6 +2419,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const args = {
owner,
repo,
host: table.project.host,
number,
updates: {
...(updates.title !== undefined ? { title: updates.title } : {}),
@@ -2495,6 +2512,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const args = {
owner,
repo,
host: table.project.host,
number,
issueTypeId: issueType?.id ?? null
}
+24
View File
@@ -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'
}
+11 -2
View File
@@ -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<string, { viewId: string }>
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