fix: reuse smart GitHub lookup on create (#2831)
This commit is contained in:
parent
4f2c671e62
commit
f8fd8f1e7d
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue