fix: reuse smart GitHub lookup on create (#2831)

This commit is contained in:
Neil 2026-05-26 01:58:48 -07:00 committed by GitHub
parent 4f2c671e62
commit f8fd8f1e7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 682 additions and 71 deletions

View File

@ -38,6 +38,7 @@ import {
parseGitHubIssueOrPRLink,
type RepoSlug
} from '@/lib/github-links'
import { lookupSmartGitHubSubmitItem } from '@/lib/smart-github-submit'
import { parseGitLabIssueOrMRLink } from '@/lib/gitlab-links'
import { cn } from '@/lib/utils'
import { LinearIcon } from '@/components/icons/LinearIcon'
@ -338,16 +339,22 @@ export default function SmartWorkspaceNameField({
}
if (!selectedSlug || sameSlug(selectedSlug, directLink.slug)) {
handledCrossRepoUrlRef.current = debouncedQuery.trim()
const item = await window.api.gh.workItemByOwnerRepo({
const item = await lookupSmartGitHubSubmitItem({
repoPath: selectedRepo.path,
repoId: selectedRepo.id,
owner: directLink.slug.owner,
repo: directLink.slug.repo,
number: directLink.number,
type: directLink.type
intent: {
kind: 'link',
owner: directLink.slug.owner,
repo: directLink.slug.repo,
number: directLink.number,
type: directLink.type
},
workItem: (args) => window.api.gh.workItem(args) as Promise<GitHubWorkItem | null>,
workItemByOwnerRepo: (args) =>
window.api.gh.workItemByOwnerRepo(args) as Promise<GitHubWorkItem | null>
})
if (!stale) {
setGithubItems(item ? [{ ...item, repoId: selectedRepo.id } as GitHubWorkItem] : [])
setGithubItems(item ? [item] : [])
}
return
}
@ -373,25 +380,28 @@ export default function SmartWorkspaceNameField({
}
if (directNumber !== null) {
setGithubLoading(true)
const request =
const intent =
directLink !== null
? window.api.gh.workItemByOwnerRepo({
repoPath: selectedRepo.path,
repoId: selectedRepo.id,
? {
kind: 'link' as const,
owner: directLink.slug.owner,
repo: directLink.slug.repo,
number: directLink.number,
type: directLink.type
})
: window.api.gh.workItem({
repoPath: selectedRepo.path,
repoId: selectedRepo.id,
number: directNumber
})
}
: { kind: 'hash-number' as const, number: directNumber }
const request = lookupSmartGitHubSubmitItem({
repoPath: selectedRepo.path,
repoId: selectedRepo.id,
intent,
workItem: (args) => window.api.gh.workItem(args) as Promise<GitHubWorkItem | null>,
workItemByOwnerRepo: (args) =>
window.api.gh.workItemByOwnerRepo(args) as Promise<GitHubWorkItem | null>
})
void request
.then((item) => {
if (!stale) {
setGithubItems(item ? [{ ...item, repoId: selectedRepo.id } as GitHubWorkItem] : [])
setGithubItems(item ? [item] : [])
}
})
.catch(() => {

View File

@ -52,6 +52,12 @@ import {
getFullComposerCreateDisabled,
getQuickComposerCreateDisabled
} from '@/lib/new-workspace-create-gates'
import {
lookupSmartGitHubSubmitItem,
getSmartGitHubSubmitIntent,
getSmartGitHubSubmitResolution,
type SmartGitHubSubmitResolution
} from '@/lib/smart-github-submit'
import {
canUseRepoBackedComposerSources,
getSelectedRepoSshGate,
@ -608,8 +614,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
[selectedRepo, selectedRepoIsGit, yamlHooks]
)
const setupPolicy: SetupRunPolicy = selectedRepo?.hookSettings?.setupRunPolicy ?? 'run-by-default'
const hasIssueAutomationConfig = enableIssueAutomation && issueCommandTemplate.length > 0
const canOfferIssueAutomation = parsedLinkedIssueNumber !== null && hasIssueAutomationConfig
// Why: the "no prompt + linked item" path below rehydrates the issueCommand
// template into the main startup prompt. When that happens we suppress the
// separate split pane that would otherwise run the same command twice.
@ -619,7 +623,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
enableIssueAutomation &&
(parsedLinkedIssueNumber !== null || willApplyIssueCommandAsPrompt) &&
!hasLoadedIssueCommand
const shouldRunIssueAutomation = canOfferIssueAutomation && !willApplyIssueCommandAsPrompt
const requiresExplicitSetupChoice = Boolean(setupConfig) && setupPolicy === 'ask'
const resolvedSetupDecision =
setupDecision ??
@ -668,22 +671,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
artifactUrl: linkedWorkItem.url
})
}, [issueCommandTemplate, linkedWorkItem, shouldApplyLinkedOnlyTemplate])
const startupPrompt = useMemo(() => {
if (shouldApplyLinkedOnlyTemplate) {
return buildAgentPromptWithContext(linkedOnlyTemplatePrompt, attachmentPaths, [])
}
return buildAgentPromptWithContext(
agentPrompt,
attachmentPaths,
linkedWorkItem?.url ? [linkedWorkItem.url] : []
)
}, [
agentPrompt,
attachmentPaths,
linkedOnlyTemplatePrompt,
linkedWorkItem?.url,
shouldApplyLinkedOnlyTemplate
])
const normalizedLinkQuery = useMemo(
() => normalizeGitHubLinkQuery(linkDebouncedQuery),
[linkDebouncedQuery]
@ -1070,6 +1057,48 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
[name]
)
const resolvePendingSmartGitHubSubmit =
useCallback(async (): Promise<SmartGitHubSubmitResolution | null> => {
if (linkedWorkItem || !selectedRepo || !selectedRepoIsGit) {
return null
}
const intent = getSmartGitHubSubmitIntent(name)
if (!intent) {
return null
}
const item = await lookupSmartGitHubSubmitItem({
repoPath: selectedRepo.path,
repoId: selectedRepo.id,
intent,
workItem: (args) => window.api.gh.workItem(args) as Promise<GitHubWorkItem | null>,
workItemByOwnerRepo: (args) =>
window.api.gh.workItemByOwnerRepo(args) as Promise<GitHubWorkItem | null>
})
if (!item) {
throw new Error('Could not resolve the GitHub item before creating the workspace.')
}
const resolution = getSmartGitHubSubmitResolution(item)
// Why: Create can be clicked before the debounced smart field commits
// its selected source. Commit the resolved item here so failures leave
// the form showing the title instead of the raw URL.
setLinkedIssue(
resolution.linkedIssueNumber !== null ? String(resolution.linkedIssueNumber) : ''
)
setLinkedPR(resolution.linkedPR)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
setLinkedWorkItem(resolution.linkedWorkItem)
setName(resolution.workspaceName)
lastAutoNameRef.current = resolution.workspaceName
setBranchNameOverride(undefined)
branchAutoNameRef.current = ''
setStartFromResetHint(null)
return resolution
}, [linkedWorkItem, name, selectedRepo, selectedRepoIsGit])
// Why: parallel of applyLinkedWorkItem for GitLab. Touches the GitLab
// state slots only — the GitHub linkedIssue/linkedPR remain unchanged
// so a workspace can in principle reference items from both providers.
@ -1480,9 +1509,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setBranchNameOverride(undefined)
branchAutoNameRef.current = ''
const repoForItem = eligibleRepos.find((repo) => repo.id === item.repoId) ?? selectedRepo
applyLinkedWorkItem(item)
if (item.type !== 'pr' || !repoForItem) {
setPushTarget(undefined)
applyLinkedWorkItem(item)
return
}
setPushTarget(undefined)
@ -1678,10 +1707,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
)
const submit = useCallback(async (): Promise<void> => {
const workspaceName = workspaceSeedName
if (
!repoId ||
!workspaceName ||
!workspaceSeedName ||
!selectedRepo ||
selectedRepoRequiresConnection ||
shouldWaitForSetupCheck ||
@ -1695,6 +1723,44 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setCreateError(null)
setCreating(true)
try {
const smartGitHubResolution = await resolvePendingSmartGitHubSubmit()
const submitLinkedWorkItem = smartGitHubResolution?.linkedWorkItem ?? linkedWorkItem
const submitLinkedIssueNumber =
smartGitHubResolution?.linkedIssueNumber ?? parsedLinkedIssueNumber
const submitLinkedPR = smartGitHubResolution?.linkedPR ?? effectiveLinkedPR
const workspaceName = smartGitHubResolution?.workspaceName ?? workspaceSeedName
if (!workspaceName) {
return
}
const submitShouldApplyLinkedOnlyTemplate =
enableIssueAutomation &&
!agentPrompt.trim() &&
Boolean(submitLinkedWorkItem) &&
hasLoadedIssueCommand
const submitLinkedOnlyTemplatePrompt =
submitShouldApplyLinkedOnlyTemplate && submitLinkedWorkItem
? renderIssueCommandTemplate(
issueCommandTemplate.trim() || DEFAULT_ISSUE_COMMAND_TEMPLATE,
{
issueNumber:
submitLinkedWorkItem.type === 'issue' ? submitLinkedWorkItem.number : null,
artifactUrl: submitLinkedWorkItem.url
}
)
: ''
const submitStartupPrompt = submitShouldApplyLinkedOnlyTemplate
? buildAgentPromptWithContext(submitLinkedOnlyTemplatePrompt, attachmentPaths, [])
: buildAgentPromptWithContext(
agentPrompt,
attachmentPaths,
submitLinkedWorkItem?.url ? [submitLinkedWorkItem.url] : []
)
const submitShouldRunIssueAutomation =
enableIssueAutomation &&
submitLinkedIssueNumber !== null &&
issueCommandTemplate.length > 0 &&
!submitShouldApplyLinkedOnlyTemplate
const setupTrustDecision = selectedRepoIsGit
? await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
: 'skip'
@ -1704,14 +1770,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
: ((resolvedSetupDecision ?? 'inherit') as SetupDecision)
let issueCommandTrustDecision: 'run' | 'skip' = 'run'
if (selectedRepoIsGit && shouldRunIssueAutomation) {
if (selectedRepoIsGit && submitShouldRunIssueAutomation) {
issueCommandTrustDecision =
setupTrustDecision === 'skip'
? 'skip'
: await ensureHooksConfirmed(useAppStore.getState(), repoId, 'issueCommand')
}
const linkedLinearIssue = linkedWorkItem?.linearIdentifier
const linkedLinearIssue = submitLinkedWorkItem?.linearIdentifier
const effectiveBranchNameOverride =
branchNameOverride && workspaceName === branchAutoNameRef.current
? branchNameOverride
@ -1728,38 +1794,36 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
: undefined,
telemetrySource,
linkedWorkItem?.title,
parsedLinkedIssueNumber ?? undefined,
effectiveLinkedPR ?? undefined,
smartGitHubResolution?.displayName ?? submitLinkedWorkItem?.title,
submitLinkedIssueNumber ?? undefined,
submitLinkedPR ?? undefined,
pushTarget,
tuiAgent,
linkedLinearIssue,
effectiveBranchNameOverride,
resolvedInitialWorkspaceStatus
resolvedInitialWorkspaceStatus,
linkedGitLabMR ?? undefined,
linkedGitLabIssue ?? undefined
)
const worktree = result.worktree
const trimmedNote = note.trim()
await applyWorktreeMeta(worktree.id, {
...(parsedLinkedIssueNumber !== null ? { linkedIssue: parsedLinkedIssueNumber } : {}),
...(effectiveLinkedPR !== null ? { linkedPR: effectiveLinkedPR } : {}),
...(linkedGitLabIssue !== null ? { linkedGitLabIssue } : {}),
...(linkedGitLabMR !== null ? { linkedGitLabMR } : {}),
...(trimmedNote ? { comment: trimmedNote } : {})
})
// Why: linked source metadata is already included in createWorktree.
// Re-saving it here can trigger slow post-create PR push-target lookups.
await applyWorktreeMeta(worktree.id, trimmedNote ? { comment: trimmedNote } : {})
const issueCommand =
shouldRunIssueAutomation && issueCommandTrustDecision === 'run'
submitShouldRunIssueAutomation && issueCommandTrustDecision === 'run'
? {
command: renderIssueCommandTemplate(issueCommandTemplate, {
issueNumber: parsedLinkedIssueNumber,
artifactUrl: linkedWorkItem?.url ?? null
issueNumber: submitLinkedIssueNumber,
artifactUrl: submitLinkedWorkItem?.url ?? null
})
}
: undefined
const startupPlan = buildAgentStartupPlan({
agent: tuiAgent,
prompt: startupPrompt,
prompt: submitStartupPrompt,
cmdOverrides: settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM
})
@ -1809,18 +1873,20 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setCreating(false)
}
}, [
agentPrompt,
attachmentPaths,
baseBranch,
branchNameOverride,
clearNewWorkspaceDraft,
createWorktree,
applyWorktreeMeta,
enableIssueAutomation,
issueCommandTemplate,
effectiveLinkedPR,
hasLoadedIssueCommand,
linkedGitLabIssue,
linkedGitLabMR,
linkedWorkItem?.linearIdentifier,
linkedWorkItem?.title,
linkedWorkItem?.url,
linkedWorkItem,
normalizedSparseDirectories,
note,
onCreated,
@ -1829,6 +1895,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
pushTarget,
repoId,
requiresExplicitSetupChoice,
resolvePendingSmartGitHubSubmit,
resolvedSetupDecision,
resolvedInitialWorkspaceStatus,
selectedRepo,
@ -1842,16 +1909,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
effectivePresetId,
telemetrySource,
tuiAgent,
shouldRunIssueAutomation,
shouldWaitForIssueAutomationCheck,
shouldWaitForSetupCheck,
startupPrompt,
workspaceSeedName
])
const submitQuick = useCallback(
async (agent: TuiAgent | null): Promise<void> => {
const workspaceName = getWorkspaceSeedName({
const workspaceNameSeed = getWorkspaceSeedName({
explicitName: name,
prompt: '',
linkedIssueNumber: parsedLinkedIssueNumber,
@ -1860,7 +1925,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
})
if (
!repoId ||
!workspaceName ||
!workspaceNameSeed ||
!selectedRepo ||
selectedRepoRequiresConnection ||
(requiresExplicitSetupChoice && !setupDecision) ||
@ -1872,6 +1937,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setCreateError(null)
setCreating(true)
try {
const smartGitHubResolution = await resolvePendingSmartGitHubSubmit()
const submitLinkedWorkItem = smartGitHubResolution?.linkedWorkItem ?? linkedWorkItem
const submitLinkedIssueNumber =
smartGitHubResolution?.linkedIssueNumber ?? parsedLinkedIssueNumber
const submitLinkedPR = smartGitHubResolution?.linkedPR ?? effectiveLinkedPR
const workspaceName = smartGitHubResolution?.workspaceName ?? workspaceNameSeed
if (!workspaceName) {
return
}
let submitSetupConfig = setupConfig
let submitResolvedSetupDecision = resolvedSetupDecision
if (selectedRepoIsGit && checkedHooksRepoId !== repoId) {
@ -1906,7 +1981,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
? 'skip'
: ((submitResolvedSetupDecision ?? 'inherit') as SetupDecision)
const linkedLinearIssue = linkedWorkItem?.linearIdentifier
const linkedLinearIssue = submitLinkedWorkItem?.linearIdentifier
const effectiveBranchNameOverride =
branchNameOverride && workspaceName === branchAutoNameRef.current
? branchNameOverride
@ -1923,14 +1998,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
: undefined,
telemetrySource,
linkedWorkItem?.title,
parsedLinkedIssueNumber ?? undefined,
effectiveLinkedPR ?? undefined,
smartGitHubResolution?.displayName ?? submitLinkedWorkItem?.title,
submitLinkedIssueNumber ?? undefined,
submitLinkedPR ?? undefined,
pushTarget,
agent ?? undefined,
linkedLinearIssue,
effectiveBranchNameOverride,
resolvedInitialWorkspaceStatus
resolvedInitialWorkspaceStatus,
linkedGitLabMR ?? undefined,
linkedGitLabIssue ?? undefined
)
const worktree = result.worktree
@ -1943,9 +2020,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// sending instead of auto-executing a "Complete <url>" template.
// Falls back to the trimmed note when the linked item carries no
// number/URL (Linear typed-only entries).
const isLinearTypedOnly = linkedWorkItem?.number === 0 && Boolean(trimmedNote)
const isLinearTypedOnly = submitLinkedWorkItem?.number === 0 && Boolean(trimmedNote)
const quickPrompt = isLinearTypedOnly && trimmedNote ? trimmedNote : ''
const quickDraftPrompt = linkedWorkItem && !isLinearTypedOnly ? linkedWorkItem.url : null
const quickDraftPrompt =
submitLinkedWorkItem && !isLinearTypedOnly ? submitLinkedWorkItem.url : null
// Why: agents that gate first-launch behind a "Do you trust this
// folder?" menu (cursor-agent, copilot) consume the bracketed paste
@ -2056,6 +2134,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
createWorktree,
fallbackCreatureName,
effectiveLinkedPR,
linkedGitLabIssue,
linkedGitLabMR,
linkedPR,
linkedWorkItem,
name,
@ -2067,6 +2147,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
pushTarget,
repoId,
requiresExplicitSetupChoice,
resolvePendingSmartGitHubSubmit,
resolvedSetupDecision,
resolvedInitialWorkspaceStatus,
selectedRepo,

View File

@ -0,0 +1,219 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearSmartGitHubSubmitLookupCacheForTests,
getSmartGitHubSubmitIntent,
getSmartGitHubSubmitResolution,
lookupSmartGitHubSubmitItem
} from './smart-github-submit'
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',
owner: 'stablyai',
repo: 'orca',
number: 2049,
type: 'pr'
})
expect(getSmartGitHubSubmitIntent('https://github.com/stablyai/orca/issues/2050')).toEqual({
kind: 'link',
owner: 'stablyai',
repo: 'orca',
number: 2050,
type: 'issue'
})
})
it('treats #number as source intent but leaves plain numbers as names', () => {
expect(getSmartGitHubSubmitIntent('#2049')).toEqual({
kind: 'hash-number',
number: 2049
})
expect(getSmartGitHubSubmitIntent('2049')).toBeNull()
})
})
describe('lookupSmartGitHubSubmitItem', () => {
beforeEach(() => {
clearSmartGitHubSubmitLookupCacheForTests()
})
it('reuses an in-flight direct URL lookup for the same repo and intent', async () => {
const item = {
id: 'pr-2049',
type: 'pr' as const,
number: 2049,
title: 'Fix smart resolution delay',
state: 'open' as const,
url: 'https://github.com/stablyai/orca/pull/2049',
labels: [],
updatedAt: '2026-05-26T00:00:00.000Z',
author: 'octocat',
repoId: 'repo-1'
}
const workItemByOwnerRepo = vi.fn().mockResolvedValue(item)
const workItem = vi.fn()
const intent = {
kind: 'link' as const,
owner: 'stablyai',
repo: 'orca',
number: 2049,
type: 'pr' as const
}
const first = lookupSmartGitHubSubmitItem({
repoId: 'repo-1',
repoPath: '/repo',
intent,
workItem,
workItemByOwnerRepo
})
const second = lookupSmartGitHubSubmitItem({
repoId: 'repo-1',
repoPath: '/repo',
intent,
workItem,
workItemByOwnerRepo
})
await expect(first).resolves.toEqual(item)
await expect(second).resolves.toEqual(item)
expect(workItemByOwnerRepo).toHaveBeenCalledTimes(1)
expect(workItem).not.toHaveBeenCalled()
})
it('scopes direct URL cache entries by repo path', async () => {
const intent = {
kind: 'link' as const,
owner: 'stablyai',
repo: 'orca',
number: 2049,
type: 'pr' as const
}
const firstItem = {
id: 'pr-2049-a',
type: 'pr' as const,
number: 2049,
title: 'First repo path',
state: 'open' as const,
url: 'https://github.com/stablyai/orca/pull/2049',
labels: [],
updatedAt: '2026-05-26T00:00:00.000Z',
author: 'octocat',
repoId: 'repo-1'
}
const secondItem = { ...firstItem, id: 'pr-2049-b', title: 'Second repo path' }
const workItemByOwnerRepo = vi
.fn()
.mockResolvedValueOnce(firstItem)
.mockResolvedValueOnce(secondItem)
const workItem = vi.fn()
await expect(
lookupSmartGitHubSubmitItem({
repoId: 'repo-1',
repoPath: '/repo-a',
intent,
workItem,
workItemByOwnerRepo
})
).resolves.toEqual(firstItem)
await expect(
lookupSmartGitHubSubmitItem({
repoId: 'repo-1',
repoPath: '/repo-b',
intent,
workItem,
workItemByOwnerRepo
})
).resolves.toEqual(secondItem)
await expect(
lookupSmartGitHubSubmitItem({
repoId: 'repo-1',
repoPath: '/repo-a',
intent,
workItem,
workItemByOwnerRepo
})
).resolves.toEqual(firstItem)
expect(workItemByOwnerRepo).toHaveBeenCalledTimes(2)
expect(workItem).not.toHaveBeenCalled()
})
it('evicts rejected direct URL lookups so immediate retries can recover', async () => {
const item = {
id: 'pr-2049',
type: 'pr' as const,
number: 2049,
title: 'Recovered lookup',
state: 'open' as const,
url: 'https://github.com/stablyai/orca/pull/2049',
labels: [],
updatedAt: '2026-05-26T00:00:00.000Z',
author: 'octocat',
repoId: 'repo-1'
}
const workItemByOwnerRepo = vi
.fn()
.mockRejectedValueOnce(new Error('temporary GitHub failure'))
.mockResolvedValueOnce(item)
const workItem = vi.fn()
const intent = {
kind: 'link' as const,
owner: 'stablyai',
repo: 'orca',
number: 2049,
type: 'pr' as const
}
const lookup = () =>
lookupSmartGitHubSubmitItem({
repoId: 'repo-1',
repoPath: '/repo',
intent,
workItem,
workItemByOwnerRepo
})
await expect(lookup()).rejects.toThrow('temporary GitHub failure')
await expect(lookup()).resolves.toEqual(item)
expect(workItemByOwnerRepo).toHaveBeenCalledTimes(2)
expect(workItem).not.toHaveBeenCalled()
})
})
describe('getSmartGitHubSubmitResolution', () => {
it('uses the resolved item title for workspace name and linked PR metadata', () => {
expect(
getSmartGitHubSubmitResolution({
type: 'pr',
number: 2049,
title: 'Fix smart resolution delay',
url: 'https://github.com/stablyai/orca/pull/2049'
})
).toEqual({
workspaceName: 'fix-smart-resolution-delay',
displayName: 'Fix smart resolution delay',
linkedWorkItem: {
type: 'pr',
number: 2049,
title: 'Fix smart resolution delay',
url: 'https://github.com/stablyai/orca/pull/2049'
},
linkedIssueNumber: null,
linkedPR: 2049
})
})
it('uses the resolved item title for workspace name and linked issue metadata', () => {
const resolution = getSmartGitHubSubmitResolution({
type: 'issue',
number: 2050,
title: 'Issue #2050: Make create feel instant',
url: 'https://github.com/stablyai/orca/issues/2050'
})
expect(resolution.workspaceName).toBe('make-create-feel-instant')
expect(resolution.linkedIssueNumber).toBe(2050)
expect(resolution.linkedPR).toBeNull()
})
})

View File

@ -0,0 +1,167 @@
import type { GitHubWorkItem } from '../../../shared/types'
import { getLinkedWorkItemSuggestedName } from '../../../shared/workspace-name'
import type { LinkedWorkItemSummary } from './new-workspace'
import { parseGitHubIssueOrPRLink } from './github-links'
export type SmartGitHubSubmitIntent =
| {
kind: 'link'
owner: string
repo: string
number: number
type: 'issue' | 'pr'
}
| {
kind: 'hash-number'
number: number
}
export type SmartGitHubSubmitResolution = {
workspaceName: string
displayName: string
linkedWorkItem: LinkedWorkItemSummary
linkedIssueNumber: number | null
linkedPR: number | null
}
export type SmartGitHubSubmitLookup = {
repoId: string
repoPath: string
intent: SmartGitHubSubmitIntent
workItem: (args: {
repoPath: string
repoId: string
number: number
}) => Promise<GitHubWorkItem | null>
workItemByOwnerRepo: (args: {
repoPath: string
repoId: string
owner: string
repo: string
number: number
type: 'issue' | 'pr'
}) => Promise<GitHubWorkItem | null>
}
const SMART_GITHUB_SUBMIT_LOOKUP_TTL_MS = 60_000
type SmartGitHubSubmitLookupCacheEntry = {
expiresAt: number
promise: Promise<GitHubWorkItem | null>
}
const smartGitHubSubmitLookupCache = new Map<string, SmartGitHubSubmitLookupCacheEntry>()
export function getSmartGitHubSubmitIntent(input: string): SmartGitHubSubmitIntent | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
const link = parseGitHubIssueOrPRLink(trimmed)
if (link) {
return {
kind: 'link',
owner: link.slug.owner,
repo: link.slug.repo,
number: link.number,
type: link.type
}
}
if (/^#\d+$/.test(trimmed)) {
return {
kind: 'hash-number',
number: Number.parseInt(trimmed.slice(1), 10)
}
}
return null
}
function getSmartGitHubSubmitLookupCacheKey({
repoId,
repoPath,
intent
}: {
repoId: string
repoPath: string
intent: SmartGitHubSubmitIntent
}): string {
const repoScope = `${repoId}:${repoPath}`
if (intent.kind === 'hash-number') {
return `${repoScope}:hash:${intent.number}`
}
return `${repoScope}:link:${intent.owner.toLowerCase()}/${intent.repo.toLowerCase()}:${
intent.type
}:${intent.number}`
}
export function lookupSmartGitHubSubmitItem({
repoId,
repoPath,
intent,
workItem,
workItemByOwnerRepo
}: SmartGitHubSubmitLookup): Promise<GitHubWorkItem | null> {
const key = getSmartGitHubSubmitLookupCacheKey({ repoId, repoPath, intent })
const now = Date.now()
const cached = smartGitHubSubmitLookupCache.get(key)
if (cached && cached.expiresAt > now) {
return cached.promise
}
const promise =
intent.kind === 'link'
? workItemByOwnerRepo({
repoPath,
repoId,
owner: intent.owner,
repo: intent.repo,
number: intent.number,
type: intent.type
})
: workItem({
repoPath,
repoId,
number: intent.number
})
const stampedPromise = promise.then((item) => (item ? { ...item, repoId } : null))
smartGitHubSubmitLookupCache.set(key, {
promise: stampedPromise,
expiresAt: now + SMART_GITHUB_SUBMIT_LOOKUP_TTL_MS
})
// Why: transient GitHub/IPC failures should dedupe while in flight, but
// must not poison immediate create retries for the full cache TTL.
void stampedPromise.catch(() => {
if (smartGitHubSubmitLookupCache.get(key)?.promise === stampedPromise) {
smartGitHubSubmitLookupCache.delete(key)
}
})
return stampedPromise
}
export function clearSmartGitHubSubmitLookupCacheForTests(): void {
smartGitHubSubmitLookupCache.clear()
}
export function getSmartGitHubSubmitResolution(
item: Pick<GitHubWorkItem, 'number' | 'title' | 'type' | 'url'>
): SmartGitHubSubmitResolution {
const fallbackName = `${item.type}-${item.number}`
const workspaceName = getLinkedWorkItemSuggestedName(item) || fallbackName
const linkedWorkItem: LinkedWorkItemSummary = {
type: item.type,
number: item.number,
title: item.title,
url: item.url
}
return {
workspaceName,
displayName: item.title,
linkedWorkItem,
linkedIssueNumber: item.type === 'issue' ? item.number : null,
linkedPR: item.type === 'pr' ? item.number : null
}
}

View File

@ -1646,6 +1646,30 @@ describe('worktree remote runtime mutations', () => {
expect(store.getState().worktreesByRepo.repo1[0]?.pushTarget).toEqual(pushTarget)
})
it('does not resolve a push target when re-saving the same linked GitHub PR', async () => {
const store = createTestStore()
const wt = makeWorktree({
id: 'repo1::/path/wt1',
repoId: 'repo1',
path: '/path/wt1',
linkedPR: 2548
})
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
worktreesByRepo: { repo1: [wt] }
} as Partial<AppState>)
await store.getState().updateWorktreeMeta(wt.id, { linkedPR: 2548 })
expect(mockApi.worktrees.resolvePrBase).not.toHaveBeenCalled()
expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({
worktreeId: wt.id,
updates: { linkedPR: 2548 }
})
})
it('does not surface remote selector misses while persisting activity timestamps', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const store = createTestStore()

View File

@ -1232,7 +1232,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
updateWorktreeMeta: async (worktreeId, updates) => {
const existingWorktree = get().getKnownWorktreeById(worktreeId)
// Why: manual PR linking only supplies the PR number. Resolve the PR head
// branch here so Push targets the review branch, not the checkout mirror.
// branch here so Push targets the review branch, but don't repeat that
// network lookup for no-op linkedPR metadata saves.
const linkedPrForPushTarget =
typeof updates.linkedPR === 'number' && Number.isFinite(updates.linkedPR)
? updates.linkedPR
@ -1241,6 +1242,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
linkedPrForPushTarget !== null &&
updates.pushTarget === undefined &&
existingWorktree &&
existingWorktree.linkedPR !== linkedPrForPushTarget &&
!existingWorktree.pushTarget
? await resolveLinkedPrPushTarget(
get().settings,

View File

@ -151,4 +151,112 @@ test.describe('Create Workspace', () => {
})
}
})
test('reuses a resolved pasted GitHub URL when quick create submits', async ({
electronApp,
orcaPage
}) => {
const title = `E2E smart URL resolution ${Date.now()}`
const url = 'https://github.com/stablyai/orca/pull/2049'
const titlePattern = new RegExp(title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
try {
await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click()
const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('combobox').first()).toBeVisible()
await electronApp.evaluate(
({ ipcMain }, { title, url }) => {
const counters = globalThis as unknown as {
__smartGitHubLookupCount: number
__smartResolvePrBaseCount: number
}
counters.__smartGitHubLookupCount = 0
counters.__smartResolvePrBaseCount = 0
ipcMain.removeHandler('gh:workItemByOwnerRepo')
ipcMain.handle(
'gh:workItemByOwnerRepo',
(
_event: unknown,
args: {
number: number
repoId?: string
}
) => {
counters.__smartGitHubLookupCount += 1
return {
id: `e2e-pr-${args.number}`,
type: 'pr',
number: args.number,
title,
state: 'open',
url,
labels: [],
updatedAt: '2026-05-26T00:00:00.000Z',
author: 'e2e',
repoId: args.repoId ?? 'e2e-repo'
}
}
)
ipcMain.removeHandler('worktrees:resolvePrBase')
ipcMain.handle('worktrees:resolvePrBase', () => {
counters.__smartResolvePrBaseCount += 1
return { baseBranch: 'origin/main' }
})
},
{ title, url }
)
const nameInput = dialog.getByPlaceholder(/Type a name/i)
await expect(nameInput).toBeVisible()
await nameInput.fill(url)
await expect
.poll(() =>
electronApp.evaluate(() => {
const counters = globalThis as unknown as {
__smartGitHubLookupCount?: number
__smartResolvePrBaseCount?: number
}
return {
githubLookupCount: counters.__smartGitHubLookupCount ?? -1,
resolvePrBaseCount: counters.__smartResolvePrBaseCount ?? -1
}
})
)
.toEqual({ githubLookupCount: 1, resolvePrBaseCount: 0 })
const createButton = dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i })
await expect(createButton).toBeEnabled()
await createButton.click()
await expect(dialog).toBeHidden({ timeout: 15_000 })
await expect(orcaPage.getByRole('option', { name: titlePattern })).toBeVisible({
timeout: 10_000
})
await expect
.poll(() =>
electronApp.evaluate(() => {
const counters = globalThis as unknown as {
__smartGitHubLookupCount?: number
__smartResolvePrBaseCount?: number
}
return {
githubLookupCount: counters.__smartGitHubLookupCount ?? -1,
resolvePrBaseCount: counters.__smartResolvePrBaseCount ?? -1
}
})
)
.toEqual({ githubLookupCount: 1, resolvePrBaseCount: 0 })
} finally {
await orcaPage
.evaluate(() => {
window.__store?.getState().closeModal()
})
.catch(() => {
/* page may already be torn down */
})
}
})
})