From f8fd8f1e7dfecb41c50df60cbcd91938fe423953 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 26 May 2026 01:58:48 -0700 Subject: [PATCH] fix: reuse smart GitHub lookup on create (#2831) --- .../new-workspace/SmartWorkspaceNameField.tsx | 44 ++-- src/renderer/src/hooks/useComposerState.ts | 187 ++++++++++----- .../src/lib/smart-github-submit.test.ts | 219 ++++++++++++++++++ src/renderer/src/lib/smart-github-submit.ts | 167 +++++++++++++ .../src/store/slices/worktrees.test.ts | 24 ++ src/renderer/src/store/slices/worktrees.ts | 4 +- tests/e2e/worktree.spec.ts | 108 +++++++++ 7 files changed, 682 insertions(+), 71 deletions(-) create mode 100644 src/renderer/src/lib/smart-github-submit.test.ts create mode 100644 src/renderer/src/lib/smart-github-submit.ts diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 136b662f8..96b9a9f6f 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -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, + workItemByOwnerRepo: (args) => + window.api.gh.workItemByOwnerRepo(args) as Promise }) 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, + workItemByOwnerRepo: (args) => + window.api.gh.workItemByOwnerRepo(args) as Promise + }) void request .then((item) => { if (!stale) { - setGithubItems(item ? [{ ...item, repoId: selectedRepo.id } as GitHubWorkItem] : []) + setGithubItems(item ? [item] : []) } }) .catch(() => { diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 41ba953ab..fb94e2d2a 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -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 => { + 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, + workItemByOwnerRepo: (args) => + window.api.gh.workItemByOwnerRepo(args) as Promise + }) + 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 => { - 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 => { - 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 " 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, diff --git a/src/renderer/src/lib/smart-github-submit.test.ts b/src/renderer/src/lib/smart-github-submit.test.ts new file mode 100644 index 000000000..847a3a375 --- /dev/null +++ b/src/renderer/src/lib/smart-github-submit.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/lib/smart-github-submit.ts b/src/renderer/src/lib/smart-github-submit.ts new file mode 100644 index 000000000..17b8aa2e6 --- /dev/null +++ b/src/renderer/src/lib/smart-github-submit.ts @@ -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 + workItemByOwnerRepo: (args: { + repoPath: string + repoId: string + owner: string + repo: string + number: number + type: 'issue' | 'pr' + }) => Promise +} + +const SMART_GITHUB_SUBMIT_LOOKUP_TTL_MS = 60_000 + +type SmartGitHubSubmitLookupCacheEntry = { + expiresAt: number + promise: Promise +} + +const smartGitHubSubmitLookupCache = new Map() + +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 { + 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 +): 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 + } +} diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index dcb34e39f..a2513f121 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -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) + + 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() diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index a0e55cda6..fbe0233a0 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1232,7 +1232,8 @@ export const createWorktreeSlice: StateCreator 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 linkedPrForPushTarget !== null && updates.pushTarget === undefined && existingWorktree && + existingWorktree.linkedPR !== linkedPrForPushTarget && !existingWorktree.pushTarget ? await resolveLinkedPrPushTarget( get().settings, diff --git a/tests/e2e/worktree.spec.ts b/tests/e2e/worktree.spec.ts index b35941a2e..2c51f1804 100644 --- a/tests/e2e/worktree.spec.ts +++ b/tests/e2e/worktree.spec.ts @@ -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 */ + }) + } + }) })